From 1e2c4e65c5b1bf556ad0ba647215c26e5e23f50c Mon Sep 17 00:00:00 2001 From: User Date: Fri, 21 Aug 2026 22:51:38 +0200 Subject: [PATCH] fix(auth): close CURRENT publication race --- backend/src/auth/runtime-projection.ts | 25 ++++++--- backend/test/auth-runtime-projection.test.ts | 58 +++++++++++++++++++- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/backend/src/auth/runtime-projection.ts b/backend/src/auth/runtime-projection.ts index 851ad797..5f83af29 100644 --- a/backend/src/auth/runtime-projection.ts +++ b/backend/src/auth/runtime-projection.ts @@ -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, diff --git a/backend/test/auth-runtime-projection.test.ts b/backend/test/auth-runtime-projection.test.ts index b76181e0..b6bccaa6 100644 --- a/backend/test/auth-runtime-projection.test.ts +++ b/backend/test/auth-runtime-projection.test.ts @@ -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(