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:
parent
71cb7f3eae
commit
decc673a5d
1 changed files with 458 additions and 26 deletions
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Reference in a new issue