fix(auth): close CURRENT publication race
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user