fix(auth): close CURRENT publication race

This commit is contained in:
User
2026-08-21 22:51:38 +02:00
parent da2f4a6681
commit 1e2c4e65c5
2 changed files with 74 additions and 9 deletions
+17 -8
View File
@@ -210,15 +210,22 @@ function entries(path: string, uid: number, expected: readonly string[]): void {
function replaced(before: Identity, after: Identity): boolean {
return before.dev !== after.dev || before.ino !== after.ino;
}
function rootIdentityAtPathAndDescriptor(
interface RootObservation {
descriptor: Identity;
path: Identity;
}
function sameObject(left: Identity, right: Identity): boolean {
return left.dev === right.dev && left.ino === right.ino;
}
function observeRootAtPathAndDescriptor(
root: string,
openedRoot: { fd: number; identity: Identity },
uid: number,
): Identity {
): RootObservation {
const openedAfter = directory(fstatSync(openedRoot.fd) as Stats, uid);
const pathAfter = directory(lstatSync(root) as Stats, uid);
if (!same(openedAfter, pathAfter)) throw invalid();
return openedAfter;
if (!sameObject(openedAfter, pathAfter)) throw invalid();
return { descriptor: openedAfter, path: pathAfter };
}
function validateRootAndCurrentAfterLoad(
root: string,
@@ -227,7 +234,7 @@ function validateRootAndCurrentAfterLoad(
selectedCurrent: Identity,
uid: number,
): void {
const rootBeforeCurrent = rootIdentityAtPathAndDescriptor(
const rootBeforeCurrent = observeRootAtPathAndDescriptor(
root,
openedRoot,
uid,
@@ -237,14 +244,16 @@ function validateRootAndCurrentAfterLoad(
uid,
MAX_SELECTOR_BYTES,
);
const rootAfterCurrent = rootIdentityAtPathAndDescriptor(
const rootAfterCurrent = observeRootAtPathAndDescriptor(
root,
openedRoot,
uid,
);
if (
!same(openedRoot.identity, rootBeforeCurrent) ||
!same(rootBeforeCurrent, rootAfterCurrent)
!same(openedRoot.identity, rootBeforeCurrent.descriptor) ||
!same(rootBeforeCurrent.descriptor, rootBeforeCurrent.path) ||
!same(rootBeforeCurrent.path, rootAfterCurrent.descriptor) ||
!same(rootAfterCurrent.descriptor, rootAfterCurrent.path)
) {
const latestCurrent = regular(
lstatSync(currentPath) as Stats,
+57 -1
View File
@@ -24,6 +24,9 @@ import { createCurrentLocalUserRegistryResolver } from "../src/auth/local-regist
const fsHook = vi.hoisted(() => ({
path: undefined as string | undefined,
callback: undefined as (() => void) | undefined,
fstatCallback: undefined as
| ((value: import("node:fs").Stats) => void)
| undefined,
rejectPathReaddir: false,
foreignGid: undefined as number | undefined,
}));
@@ -48,7 +51,9 @@ vi.mock("node:fs", async (importOriginal) => {
return result;
},
fstatSync(fd: number) {
return observed(actual.fstatSync(fd));
const result = observed(actual.fstatSync(fd));
fsHook.fstatCallback?.(result);
return result;
},
readdirSync(path: import("node:fs").PathLike) {
if (fsHook.rejectPathReaddir)
@@ -68,6 +73,7 @@ const roots: string[] = [];
afterEach(() => {
fsHook.path = undefined;
fsHook.callback = undefined;
fsHook.fstatCallback = undefined;
fsHook.rejectPathReaddir = false;
fsHook.foreignGid = undefined;
for (const root of roots.splice(0))
@@ -592,6 +598,56 @@ test("retries once when CURRENT is replaced after the final identity read", () =
expect(observations).toBe(3);
});
test("retries once when CURRENT is replaced between root descriptor and path observations", () => {
const root = projectionRoot();
const first = writeReadyProjection(
root,
localProjectionFixture("first", passwordHash),
);
const second = writeGeneration(
root,
localProjectionFixture("second", passwordHash),
);
const stagedParent = mkdtempSync(
join(tmpdir(), "tht-auth-projection-root-observation-"),
);
roots.push(stagedParent);
renameSync(join(root, "generations", second), join(stagedParent, second));
const rootIdentity = lstatSync(root);
let armed = false;
let replacedCurrent = false;
fsHook.path = join(root, "generations", first, "users.yaml");
fsHook.callback = () => {
armed = true;
fsHook.callback = undefined;
};
fsHook.fstatCallback = (value) => {
if (
!armed ||
replacedCurrent ||
value.dev !== rootIdentity.dev ||
value.ino !== rootIdentity.ino
)
return;
replacedCurrent = true;
renameSync(join(stagedParent, second), join(root, "generations", second));
const temporary = join(root, ".current-root-observation.tmp");
writeFileSync(temporary, currentDocument(second, [first]), {
encoding: "utf8",
mode: 0o600,
});
chmodSync(temporary, 0o600);
renameSync(temporary, join(root, "CURRENT"));
};
expect(
createProjectedAuthenticationConfigProvider(root).current()
.runtimeProjection?.generation,
).toBe(second);
expect(replacedCurrent).toBe(true);
});
test("rejects a second CURRENT replacement after the one permitted retry", () => {
const root = projectionRoot();
const first = writeReadyProjection(