fix(auth): harden restore verification transaction

This commit is contained in:
2026-08-18 00:58:12 +02:00
parent 0651f3316f
commit dee17893b4
10 changed files with 698 additions and 115 deletions
+116 -6
View File
@@ -1,7 +1,7 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
chmodSync, existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync,
chmodSync, existsSync, lstatSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, utimesSync, writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -338,21 +338,70 @@ function persistedState(root: string, commit: string): { active: any; manifest:
};
}
function filesystemFingerprint(root: string): string {
if (!existsSync(root)) return "absent";
const records: string[] = [];
const visit = (path: string, relative: string): void => {
const entry = lstatSync(path);
const metadata = [
entry.dev, entry.ino, entry.mode, entry.uid, entry.gid,
entry.size, entry.mtimeMs, entry.ctimeMs,
].join(":");
if (entry.isSymbolicLink()) {
records.push(`link:${relative}:${metadata}`);
return;
}
if (entry.isDirectory()) {
records.push(`directory:${relative}:${metadata}`);
for (const name of readdirSync(path).sort()) visit(join(path, name), relative === "." ? name : `${relative}/${name}`);
return;
}
if (entry.isFile()) {
const contents = readFileSync(path);
records.push(`file:${relative}:${metadata}:${contents.length}:${createHash("sha256").update(contents).digest("hex")}`);
return;
}
records.push(`other:${relative}:${metadata}`);
};
visit(root, ".");
return createHash("sha256").update(records.join("\n")).digest("hex");
}
test("verifies a never-initialized registry without contacting its remote", async () => {
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-registry-uninitialized-"));
temporaryRoots.push(root);
const registryRoot = join(root, "registry");
mkdirSync(registryRoot, { mode: 0o700 });
const registry = new WorkspaceRegistry(config(
registryRoot,
join(root, "missing-remote.git"),
));
const before = filesystemFingerprint(registryRoot);
await expect(registry.verifyStoredState()).resolves.toEqual({
state: "uninitialized",
workspaces: 0,
fingerprint: `sha256:${before}`,
});
expect(filesystemFingerprint(registryRoot)).toBe(before);
expect(existsSync(join(registryRoot, "repo"))).toBe(false);
expect(readdirSync(join(registryRoot, "state"))).toEqual([]);
expect(readdirSync(registryRoot)).toEqual([]);
});
test("never-initialized inspection refuses an absent or partial root without creating it", async () => {
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-registry-absent-"));
temporaryRoots.push(root);
const registryRoot = join(root, "registry");
const registry = new WorkspaceRegistry(config(registryRoot, join(root, "missing-remote.git")));
await expect(registry.verifyStoredState()).rejects.toMatchObject({ code: "workspace_invalid" });
expect(filesystemFingerprint(registryRoot)).toBe("absent");
mkdirSync(join(registryRoot, "state"), { recursive: true });
const partial = filesystemFingerprint(registryRoot);
await expect(registry.verifyStoredState()).rejects.toMatchObject({ code: "workspace_invalid" });
expect(filesystemFingerprint(registryRoot)).toBe(partial);
});
test("verifies initialized snapshots and rejects partial or malformed persisted state", async () => {
@@ -361,10 +410,25 @@ test("verifies initialized snapshots and rejects partial or malformed persisted
const registry = new WorkspaceRegistry(config(registryRoot, remote.remote));
await registry.bootstrap();
await expect(registry.verifyStoredState()).resolves.toEqual({
state: "active",
workspaces: 1,
});
const before = filesystemFingerprint(registryRoot);
const savedPath = process.env.PATH;
process.env.PATH = join(remote.root, "no-executables");
try {
await expect(registry.verifyStoredState()).resolves.toEqual({
state: "active",
workspaces: 1,
fingerprint: `sha256:${before}`,
});
} finally {
if (savedPath === undefined) delete process.env.PATH;
else process.env.PATH = savedPath;
}
expect(filesystemFingerprint(registryRoot)).toBe(before);
const firstIntegrity = await registry.verifyStoredState();
utimesSync(join(registryRoot, "repo"), new Date(1_000), new Date(1_000));
const metadataIntegrity = await registry.verifyStoredState();
expect(metadataIntegrity.fingerprint).not.toBe(firstIntegrity.fingerprint);
rmSync(join(registryRoot, "state", "active.json"));
await expect(registry.verifyStoredState()).rejects.toMatchObject({ code: "workspace_invalid" });
@@ -373,6 +437,52 @@ test("verifies initialized snapshots and rejects partial or malformed persisted
await expect(registry.verifyStoredState()).rejects.toMatchObject({ code: "workspace_invalid" });
});
test("read-only inspection rejects extra components, links, and ephemeral runtime contents", async () => {
const remote = await fixture();
const registryRoot = join(remote.root, "registry");
const registry = new WorkspaceRegistry(config(registryRoot, remote.remote));
const status = await registry.bootstrap();
const assertHostile = async (setup: () => void, cleanup: () => void): Promise<void> => {
setup();
const before = filesystemFingerprint(registryRoot);
await expect(registry.verifyStoredState()).rejects.toMatchObject({ code: "workspace_invalid" });
expect(filesystemFingerprint(registryRoot)).toBe(before);
cleanup();
};
await assertHostile(
() => mkdirSync(join(registryRoot, "unexpected")),
() => rmSync(join(registryRoot, "unexpected"), { recursive: true }),
);
await assertHostile(
() => rmSync(join(registryRoot, "locks", "empty-hooks"), { recursive: true }),
() => mkdirSync(join(registryRoot, "locks", "empty-hooks")),
);
await assertHostile(
() => writeFileSync(join(registryRoot, "state", "unexpected.json"), "{}"),
() => rmSync(join(registryRoot, "state", "unexpected.json")),
);
await assertHostile(
() => writeFileSync(join(registryRoot, "snapshots", status.head!, "unexpected"), "extra"),
() => rmSync(join(registryRoot, "snapshots", status.head!, "unexpected")),
);
await assertHostile(
() => {
mkdirSync(join(registryRoot, "snapshots", "runtime"), { recursive: true });
writeFileSync(join(registryRoot, "snapshots", "runtime", "restored-secret.yaml"), "secret: forbidden");
},
() => rmSync(join(registryRoot, "snapshots", "runtime"), { recursive: true }),
);
await assertHostile(
() => symlinkSync(join(registryRoot, "state", "active.json"), join(registryRoot, "linked-active.json")),
() => rmSync(join(registryRoot, "linked-active.json")),
);
await assertHostile(
() => symlinkSync(join(registryRoot, "state", "active.json"), join(registryRoot, "repo", "linked-active.json")),
() => rmSync(join(registryRoot, "repo", "linked-active.json")),
);
});
test("rejects a catalog entry without a descriptor instead of creating a bootstrap slot", async () => {
const remote = await contentOnlyFixture();
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));