fix(auth): harden restore verification transaction
This commit is contained in:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user