|
52 | 52 | import java.nio.file.attribute.FileTime; |
53 | 53 | import java.nio.file.attribute.PosixFilePermission; |
54 | 54 | import java.nio.file.attribute.PosixFilePermissions; |
| 55 | +import java.util.Arrays; |
55 | 56 | import java.util.Set; |
56 | 57 | import java.util.UUID; |
57 | 58 |
|
@@ -113,6 +114,10 @@ public final class FileTokenStore implements TokenStore { |
113 | 114 | // to a lock-free refresh (Layer 1 still guards integrity). Stays well below DEFAULT_LOCK_STALE_MILLIS so a |
114 | 115 | // waiter degrades long before it could begin stealing live locks. |
115 | 116 | private static final long MAX_LOCK_ACQUIRE_BUDGET_MILLIS = 30_000L; |
| 117 | + // reject a lock file larger than this before reading it: the <hash>.lock file sits in the same |
| 118 | + // attacker-writable directory as the token file, and a real owner stamp (pid@host + millis + UUID) is a |
| 119 | + // few hundred bytes, so anything past this cap is corrupt or hostile and is not read into memory |
| 120 | + private static final int MAX_LOCK_FILE_BYTES = 1 << 12; |
116 | 121 | private static final int SCHEMA_VERSION = 1; |
117 | 122 | // set once if the platform cannot enforce owner-only POSIX permissions on the token files (e.g. Windows), |
118 | 123 | // so the at-rest protection falls back to the directory's inherited ACL; warns the user once |
@@ -295,6 +300,16 @@ private static void createLockFile(Path lock) throws IOException { |
295 | 300 | } |
296 | 301 | } |
297 | 302 |
|
| 303 | + private static void deleteCapturedLock(Path captured) { |
| 304 | + // best-effort cleanup of a lock we atomically captured during a steal; a leftover .tmp is reclaimed by |
| 305 | + // sweepStaleTempFiles on a later save |
| 306 | + try { |
| 307 | + Files.deleteIfExists(captured); |
| 308 | + } catch (IOException ignore) { |
| 309 | + // reclaimed by sweepStaleTempFiles later |
| 310 | + } |
| 311 | + } |
| 312 | + |
298 | 313 | private static String newLockNonce() { |
299 | 314 | // a per-acquisition owner stamp: the pid@host and the acquire time are human-readable debugging aids, |
300 | 315 | // and the random UUID guarantees two acquisitions never share a stamp even within one pid and one |
@@ -453,20 +468,49 @@ private static byte[] readBounded(Path file) throws IOException { |
453 | 468 | } |
454 | 469 | } |
455 | 470 |
|
| 471 | + private static byte[] readLockHolder(Path lock) throws IOException { |
| 472 | + // read the lock's owner stamp with a hard cap rather than Files.readAllBytes: the <hash>.lock file |
| 473 | + // sits in the same attacker-writable directory as the token file, so an inflated lock would otherwise |
| 474 | + // make readAllBytes allocate without bound and throw OutOfMemoryError - an Error the best-effort |
| 475 | + // RuntimeException guards on the getToken()/signIn() refresh path would not catch, aborting the |
| 476 | + // sign-in (the same reason readBounded caps the token file). A real owner stamp is a few hundred |
| 477 | + // bytes; anything past the cap is corrupt or hostile, so report it as unreadable (null) rather than |
| 478 | + // read it into memory. Returns the exact bytes present, or null for an empty/oversized lock. |
| 479 | + try (FileChannel channel = FileChannel.open(lock, StandardOpenOption.READ)) { |
| 480 | + long size = channel.size(); |
| 481 | + if (size <= 0 || size > MAX_LOCK_FILE_BYTES) { |
| 482 | + return null; |
| 483 | + } |
| 484 | + ByteBuffer buffer = ByteBuffer.allocate((int) size); |
| 485 | + while (buffer.hasRemaining() && channel.read(buffer) >= 0) { |
| 486 | + // read until EOF or the buffer fills |
| 487 | + } |
| 488 | + int read = buffer.position(); |
| 489 | + if (read == 0) { |
| 490 | + return null; |
| 491 | + } |
| 492 | + byte[] bytes = new byte[read]; |
| 493 | + buffer.flip(); |
| 494 | + buffer.get(bytes); |
| 495 | + return bytes; |
| 496 | + } |
| 497 | + } |
| 498 | + |
456 | 499 | private static void releaseLock(Path lock, String nonce) { |
457 | | - // release our own lock only: re-read it and delete it solely when it still carries our nonce. A hold |
458 | | - // that outran lockStaleMillis may have been judged stale and stolen (deleted and recreated) by a peer; |
459 | | - // deleting by bare path would then remove the peer's live lock and admit a third acquirer alongside it, |
460 | | - // defeating the mutual exclusion this lock exists to provide. A microscopic window remains if a steal |
461 | | - // lands between the read and the delete, but that is bounded to one syscall gap rather than the whole |
462 | | - // hold, so a misconfigured staleness window degrades to at most the documented double-refresh rather |
463 | | - // than corrupting a peer's lock state. |
| 500 | + // release our own lock only: re-read it (bounded - see readLockHolder) and delete it solely when it |
| 501 | + // still carries our nonce. A hold that outran lockStaleMillis may have been judged stale and stolen |
| 502 | + // (captured and recreated) by a peer; deleting by bare path would then remove the peer's live lock and |
| 503 | + // admit a third acquirer alongside it, defeating the mutual exclusion this lock exists to provide. A |
| 504 | + // microscopic window remains if a steal lands between the read and the delete, but that is bounded to |
| 505 | + // one syscall gap rather than the whole hold, so a misconfigured staleness window degrades to at most |
| 506 | + // the documented double-refresh rather than corrupting a peer's lock state. |
464 | 507 | try { |
465 | | - byte[] content = Files.readAllBytes(lock); |
466 | | - if (nonce.equals(new String(content, StandardCharsets.UTF_8))) { |
| 508 | + byte[] content = readLockHolder(lock); |
| 509 | + if (content != null && nonce.equals(new String(content, StandardCharsets.UTF_8))) { |
467 | 510 | Files.deleteIfExists(lock); |
468 | 511 | } |
469 | | - // otherwise a peer now owns this lock file; leave it for that owner (or staleness) to reclaim |
| 512 | + // otherwise a peer now owns this lock file, or it is unreadable/oversized; leave it for that owner |
| 513 | + // (or the staleness steal) to reclaim |
470 | 514 | } catch (NoSuchFileException e) { |
471 | 515 | // already gone (stolen and not yet recreated, or removed elsewhere); nothing to release |
472 | 516 | } catch (IOException ignore) { |
@@ -578,16 +622,11 @@ private String acquireLock(Path lock) { |
578 | 622 | } |
579 | 623 | return null; |
580 | 624 | } catch (FileAlreadyExistsException e) { |
581 | | - if (isStale(lock)) { |
582 | | - // a crashed holder left the lock behind; steal it |
583 | | - try { |
584 | | - Files.deleteIfExists(lock); |
585 | | - } catch (IOException ignore) { |
586 | | - // another acquirer may have removed it; the next createLockFile settles the race |
587 | | - } |
588 | | - // fall through to the bounded wait below rather than retry immediately: a steal contest |
589 | | - // between several acquirers (or a misconfigured tiny lockStaleMillis) must not hot-spin |
590 | | - } |
| 625 | + // the lock exists; if a crashed holder abandoned it, steal it - atomically and stamp-verified, |
| 626 | + // so a stealer never removes a peer's freshly-created live lock (see stealIfStale). Then fall |
| 627 | + // through to the bounded wait below rather than retry immediately: a steal contest between |
| 628 | + // several acquirers (or a misconfigured tiny lockStaleMillis) must not hot-spin. |
| 629 | + stealIfStale(lock); |
591 | 630 | if (System.currentTimeMillis() >= deadline) { |
592 | 631 | return null; // give up and run without the lock rather than stall a sign-in |
593 | 632 | } |
@@ -637,6 +676,61 @@ private Path lockFile(TokenStoreKey key) { |
637 | 676 | return directory.resolve(key.hash() + ".lock"); |
638 | 677 | } |
639 | 678 |
|
| 679 | + private void stealIfStale(Path lock) { |
| 680 | + // Steal a lock abandoned by a crashed holder, but never remove a peer's freshly-created LIVE lock. A |
| 681 | + // bare deleteIfExists(lock) removes whatever sits at the path at that instant - including a fresh lock |
| 682 | + // a peer created in the gap since we judged the old one stale - and would admit two holders at once. |
| 683 | + // Instead: read the current owner stamp, confirm the lock is stale, then capture it atomically into a |
| 684 | + // private name (rename is atomic, so among racing stealers exactly one captures it; the losers get |
| 685 | + // NoSuchFileException and fall back to the wait), then verify what we captured carries the same stamp |
| 686 | + // we judged stale. If a peer had already replaced it with a live lock we grabbed that instead, so we |
| 687 | + // put it back rather than steal it. This mirrors releaseLock's own-stamp check and shrinks the |
| 688 | + // residual race from the whole isStale->delete gap to the gap between the two renames. |
| 689 | + final byte[] before; |
| 690 | + try { |
| 691 | + before = readLockHolder(lock); |
| 692 | + } catch (IOException e) { |
| 693 | + return; // gone or unreadable; nothing to steal here - the create attempt or a peer settles it |
| 694 | + } |
| 695 | + // read the stamp first, then check staleness: if a peer replaces the stale lock with a fresh one in |
| 696 | + // between, isStale reads the fresh mtime and returns false, so we never proceed against a live lock |
| 697 | + if (!isStale(lock)) { |
| 698 | + return; |
| 699 | + } |
| 700 | + final Path captured = lock.resolveSibling(lock.getFileName().toString() + '.' + UUID.randomUUID() + ".tmp"); |
| 701 | + try { |
| 702 | + Files.move(lock, captured, StandardCopyOption.ATOMIC_MOVE); |
| 703 | + } catch (IOException e) { |
| 704 | + // NoSuchFile: a peer already stole/removed it; AtomicMoveNotSupported or other IO: degrade and |
| 705 | + // leave the lock for the staleness path. Either way we have not removed a peer's live lock. |
| 706 | + return; |
| 707 | + } |
| 708 | + byte[] after = null; |
| 709 | + try { |
| 710 | + after = readLockHolder(captured); |
| 711 | + } catch (IOException ignore) { |
| 712 | + // captured but cannot re-read; treated as a non-match below, so we restore rather than steal |
| 713 | + } |
| 714 | + // confirm we captured the same stamp we judged stale (or the same unreadable/empty junk), not a live |
| 715 | + // lock a peer recreated in the gap |
| 716 | + final boolean confirmedStale = before == null ? after == null : Arrays.equals(before, after); |
| 717 | + if (confirmedStale) { |
| 718 | + // genuinely the abandoned lock: drop it, so the next createLockFile can claim a fresh one |
| 719 | + deleteCapturedLock(captured); |
| 720 | + return; |
| 721 | + } |
| 722 | + // we captured a live lock a peer recreated in the gap: put it back rather than steal it. Use a plain |
| 723 | + // move (not ATOMIC_MOVE, which maps to rename(2) and would replace the target): if a third party |
| 724 | + // claimed the now-free path during our capture window, FileAlreadyExistsException leaves their lock |
| 725 | + // intact and we drop our captured copy rather than clobber it; a stray .tmp is reclaimed by |
| 726 | + // sweepStaleTempFiles on a later save. |
| 727 | + try { |
| 728 | + Files.move(captured, lock); |
| 729 | + } catch (IOException e) { |
| 730 | + deleteCapturedLock(captured); |
| 731 | + } |
| 732 | + } |
| 733 | + |
640 | 734 | private void sweepStaleTempFiles(String hashPrefix) { |
641 | 735 | // a crash between createTempFile and the atomic rename orphans a <hash>*.tmp holding a |
642 | 736 | // valid-at-the-time refresh token; unlike the lock file nothing ever steals it, so it would accumulate |
|
0 commit comments