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 {
|
function replaced(before: Identity, after: Identity): boolean {
|
||||||
return before.dev !== after.dev || before.ino !== after.ino;
|
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,
|
root: string,
|
||||||
openedRoot: { fd: number; identity: Identity },
|
openedRoot: { fd: number; identity: Identity },
|
||||||
uid: number,
|
uid: number,
|
||||||
): Identity {
|
): RootObservation {
|
||||||
const openedAfter = directory(fstatSync(openedRoot.fd) as Stats, uid);
|
const openedAfter = directory(fstatSync(openedRoot.fd) as Stats, uid);
|
||||||
const pathAfter = directory(lstatSync(root) as Stats, uid);
|
const pathAfter = directory(lstatSync(root) as Stats, uid);
|
||||||
if (!same(openedAfter, pathAfter)) throw invalid();
|
if (!sameObject(openedAfter, pathAfter)) throw invalid();
|
||||||
return openedAfter;
|
return { descriptor: openedAfter, path: pathAfter };
|
||||||
}
|
}
|
||||||
function validateRootAndCurrentAfterLoad(
|
function validateRootAndCurrentAfterLoad(
|
||||||
root: string,
|
root: string,
|
||||||
@@ -227,7 +234,7 @@ function validateRootAndCurrentAfterLoad(
|
|||||||
selectedCurrent: Identity,
|
selectedCurrent: Identity,
|
||||||
uid: number,
|
uid: number,
|
||||||
): void {
|
): void {
|
||||||
const rootBeforeCurrent = rootIdentityAtPathAndDescriptor(
|
const rootBeforeCurrent = observeRootAtPathAndDescriptor(
|
||||||
root,
|
root,
|
||||||
openedRoot,
|
openedRoot,
|
||||||
uid,
|
uid,
|
||||||
@@ -237,14 +244,16 @@ function validateRootAndCurrentAfterLoad(
|
|||||||
uid,
|
uid,
|
||||||
MAX_SELECTOR_BYTES,
|
MAX_SELECTOR_BYTES,
|
||||||
);
|
);
|
||||||
const rootAfterCurrent = rootIdentityAtPathAndDescriptor(
|
const rootAfterCurrent = observeRootAtPathAndDescriptor(
|
||||||
root,
|
root,
|
||||||
openedRoot,
|
openedRoot,
|
||||||
uid,
|
uid,
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
!same(openedRoot.identity, rootBeforeCurrent) ||
|
!same(openedRoot.identity, rootBeforeCurrent.descriptor) ||
|
||||||
!same(rootBeforeCurrent, rootAfterCurrent)
|
!same(rootBeforeCurrent.descriptor, rootBeforeCurrent.path) ||
|
||||||
|
!same(rootBeforeCurrent.path, rootAfterCurrent.descriptor) ||
|
||||||
|
!same(rootAfterCurrent.descriptor, rootAfterCurrent.path)
|
||||||
) {
|
) {
|
||||||
const latestCurrent = regular(
|
const latestCurrent = regular(
|
||||||
lstatSync(currentPath) as Stats,
|
lstatSync(currentPath) as Stats,
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ import { createCurrentLocalUserRegistryResolver } from "../src/auth/local-regist
|
|||||||
const fsHook = vi.hoisted(() => ({
|
const fsHook = vi.hoisted(() => ({
|
||||||
path: undefined as string | undefined,
|
path: undefined as string | undefined,
|
||||||
callback: undefined as (() => void) | undefined,
|
callback: undefined as (() => void) | undefined,
|
||||||
|
fstatCallback: undefined as
|
||||||
|
| ((value: import("node:fs").Stats) => void)
|
||||||
|
| undefined,
|
||||||
rejectPathReaddir: false,
|
rejectPathReaddir: false,
|
||||||
foreignGid: undefined as number | undefined,
|
foreignGid: undefined as number | undefined,
|
||||||
}));
|
}));
|
||||||
@@ -48,7 +51,9 @@ vi.mock("node:fs", async (importOriginal) => {
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
fstatSync(fd: number) {
|
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) {
|
readdirSync(path: import("node:fs").PathLike) {
|
||||||
if (fsHook.rejectPathReaddir)
|
if (fsHook.rejectPathReaddir)
|
||||||
@@ -68,6 +73,7 @@ const roots: string[] = [];
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
fsHook.path = undefined;
|
fsHook.path = undefined;
|
||||||
fsHook.callback = undefined;
|
fsHook.callback = undefined;
|
||||||
|
fsHook.fstatCallback = undefined;
|
||||||
fsHook.rejectPathReaddir = false;
|
fsHook.rejectPathReaddir = false;
|
||||||
fsHook.foreignGid = undefined;
|
fsHook.foreignGid = undefined;
|
||||||
for (const root of roots.splice(0))
|
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);
|
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", () => {
|
test("rejects a second CURRENT replacement after the one permitted retry", () => {
|
||||||
const root = projectionRoot();
|
const root = projectionRoot();
|
||||||
const first = writeReadyProjection(
|
const first = writeReadyProjection(
|
||||||
|
|||||||
Reference in New Issue
Block a user