fix(auth): address Copilot review round 5, and land the impl its tests need

Repairs origin as well as answering the review. 2d2c787e committed the
`absorbFileSecretsIntoKeyring` tests without the implementation they
import, so the branch has been failing `tsc` since; this commit brings the
source across.

Round 5's four findings (two inline, two suppressed):

- **The stale-lock takeover was still racy.** Round 4 made *stealing*
  liveness-aware but left the takeover as remove-then-recreate, which is
  not an election: two waiters can both judge the same lock stale, the
  first removes it and takes a fresh one, and the second then removes
  *that* — both inside the section, which is the lost update the takeover
  exists to prevent. Claiming is now an atomic `rename` to a
  token-unique name, so exactly one waiter can take a given stale lock and
  the losers get ENOENT. A failed claim also falls through to the deadline
  instead of `continue`ing, so a takeover that can never succeed times out
  rather than spinning forever on a swallowed error.

- **Every `stat` failure was read as "no file yet".** An existing file we
  cannot inspect (EACCES on the directory, a mode-less filesystem) then
  produced no warning at all, and the footer went on stating owner-only
  permissions having verified nothing. There is now a third state,
  `unknown`, carried end to end: `permissionsUnknown` on the descriptor, a
  caveat that says the permissions could not be checked, a `warn` tone, and
  its own branch in the footer sentence — which otherwise would have
  claimed "Owner-only permissions." by omission, the same bug one layer up.

- **mountinfo decoded only `\040`.** The kernel escapes tab, newline and
  backslash too (`\011`, `\012`, `\134`), so a mounted path containing any
  of them was compared in its escaped form, matched nothing, and read as
  the container's writable layer — `memory` selected and persistence
  silently lost on a box that had arranged for it. Decoded as octal in
  general now, since the rule is octal-in-general and a future addition
  would otherwise reintroduce exactly this.

- **File-backed secrets vanished once a keychain appeared.** Save secrets
  without libsecret, install libsecret, and the next run selects the
  keychain and stops seeing them: still on disk, read by nothing, and
  nothing looks broken. `absorbFileSecretsIntoKeyring` hands them over on
  the run that finds a keychain, under the rules the two existing plaintext
  migrations already use — keychain wins on conflict, the file is removed
  only on complete success, and a file that cannot be read is never
  deleted, because that is precisely the case where the values would be
  lost.

Tests: a two-waiter race over one stale lock asserting a single holder
throughout; `unknown` from `readSecretFilePermissions` through the
descriptor to the footer sentence and tone; the hand-off's happy path,
keychain-wins, write-failure, undecryptable-file, empty-file,
malformed-key, and explicitly-configured-keyring cases; and six more
rejected descriptor shapes, including each file-only field on a non-file
kind. Full `npm run ci` green — 5,732 tests under the coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 14:15:35 -04:00
commit decc673a5d

View file

@ -382,8 +382,29 @@ export async function acquireLock(
}
if (code !== "EEXIST") return noop; // cannot lock here at all — see above
if (await canSteal(lockDir, ownerFile, staleMs)) {
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => {});
continue;
// Claim the stale lock by *renaming* it, not by removing it.
//
// `rm` is not an election: two waiters can both judge the same lock
// stale, and by the time the second one runs, the first may already
// have removed it and created a fresh lock of its own — which the
// second then deletes, and both enter the section together. That is
// the lost update, reached through the takeover meant to prevent it.
//
// `rename` is atomic and single-winner: exactly one waiter moves the
// directory aside, and the losers get ENOENT and fall through to
// retry against whatever the winner does next. The unique
// destination (our token) also means two winners of *different*
// stale locks cannot collide.
const claimed = `${lockDir}.stale-${token}`;
try {
await fs.rename(lockDir, claimed);
await fs.rm(claimed, { recursive: true, force: true });
continue;
} catch {
// Lost the election, or the rename failed outright. Either way,
// fall through to the deadline check rather than looping — a
// takeover that can never succeed must time out, not spin.
}
}
if (Date.now() >= deadline)
throw new SecretFileLockTimeoutError(filePath);
@ -566,6 +587,25 @@ export class FileSecretStore implements SecretStore {
* throw for "there is a file and we cannot read it" — which must never be
* flattened into an empty map, or `set` would cheerfully overwrite it.
*/
/**
* Every secret in the file, keyed by `${serverId}:${field}` — `null` when
* there is no file.
*
* Exists for one caller: the hand-off performed when a keychain becomes
* available on a box that had been falling back to a file (see
* `absorbFileSecretsIntoKeyring`). Nothing in the normal request path
* enumerates secrets, and deliberately so — this is the widest possible
* read of the store and it should stay a one-purpose seam rather than a
* general convenience.
*
* Throws for the same reasons `get` swallows: an unreadable or
* undecryptable file must not present as "no secrets", because the caller
* would conclude there was nothing to migrate and move on.
*/
async readAll(): Promise<Record<string, string> | null> {
return this.serialize(() => this.readMap());
}
private async readMap(): Promise<Record<string, string> | null> {
const raw = await readStoreFile(this.filePath);
if (raw === null) return null;
@ -830,10 +870,19 @@ export async function readSecretFilePermissions(
const mode = (await fs.stat(filePath)).mode & 0o777;
if (mode === 0o600) return { state: "ok" };
return { state: "loose", mode };
} catch {
// No file yet — the common first run. `writeStoreFile` creates it 0600,
// so there is nothing to warn about.
return { state: "absent" };
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
// No file yet — the common first run. `writeStoreFile` creates it
// 0600, so there is nothing to warn about.
return { state: "absent" };
}
// The file exists and we could not inspect it (EACCES on the directory,
// a permission-less filesystem). Collapsing this into "absent" is what
// makes it dangerous: the descriptor then omits the warning and the
// footer goes on stating mode 0600 as fact, having verified nothing.
// "Unknown" is the honest third answer.
return { state: "unknown", detail: code ?? "unknown error" };
}
}
@ -846,4 +895,6 @@ export async function readSecretFilePermissions(
export type SecretFilePermissionState =
| { state: "ok" }
| { state: "absent" }
| { state: "loose"; mode: number };
| { state: "loose"; mode: number }
/** The file is there and its mode could not be read — see above. */
| { state: "unknown"; detail: string };