fix(auth): harden restore verification transaction
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { lstatSync, readFileSync } from "node:fs";
|
||||
import { lstatSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { buildInstallationContract, renderWorkspaceDocs } from "./contracts.js";
|
||||
@@ -34,6 +34,7 @@ export interface WorkspaceRevision {
|
||||
export interface StoredWorkspaceIntegrity {
|
||||
state: "uninitialized" | "active";
|
||||
workspaces: number;
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
export interface SessionRevisionLease {
|
||||
@@ -188,43 +189,137 @@ export class WorkspaceRegistry {
|
||||
/**
|
||||
* Validate only persisted local registry state. Restore uses this path while the installation
|
||||
* is stopped: it must neither contact Git nor turn a never-used registry into initialized state.
|
||||
* The only never-initialized shape is an existing empty root. An initialized root is exactly
|
||||
* repo/, snapshots/, state/, and locks/: locks contains repository.lock plus an empty
|
||||
* empty-hooks/, state contains active.json plus an optional empty revision-leases/, and
|
||||
* snapshots contains exact immutable commit snapshots plus an optional empty runtime/.
|
||||
* Descendants may contain only ordinary directories and regular files; links and special files
|
||||
* are rejected by the stable whole-tree fingerprint before any shape is accepted.
|
||||
*/
|
||||
async verifyStoredState(): Promise<StoredWorkspaceIntegrity> {
|
||||
await this.repository.ensureLayout();
|
||||
return await this.lock.run(async () => {
|
||||
try {
|
||||
const stateEntries = await readdir(this.repository.statePath, { withFileTypes: true });
|
||||
if (stateEntries.some((entry) => (
|
||||
entry.name !== "active.json" || !entry.isFile() || entry.isSymbolicLink()
|
||||
))) throw new Error("workspace state directory is partial");
|
||||
|
||||
const snapshotEntries = await readdir(this.repository.snapshotsPath, { withFileTypes: true });
|
||||
for (const entry of snapshotEntries) {
|
||||
const isRuntime = entry.name === "runtime";
|
||||
const isSnapshot = /^[0-9a-f]{40}$/.test(entry.name);
|
||||
if ((!isRuntime && !isSnapshot) || !entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
throw new Error("workspace snapshot directory is partial");
|
||||
}
|
||||
}
|
||||
|
||||
const hasActiveState = stateEntries.length === 1;
|
||||
if (!hasActiveState) {
|
||||
if (snapshotEntries.some((entry) => entry.name !== "runtime") || this.storedPathExists(this.repository.repoPath)) {
|
||||
throw new Error("workspace registry is partially initialized");
|
||||
}
|
||||
return { state: "uninitialized", workspaces: 0 };
|
||||
}
|
||||
|
||||
const active = await this.activeState();
|
||||
for (const entry of snapshotEntries) {
|
||||
if (entry.name === "runtime" || entry.name === active.head) continue;
|
||||
await this.snapshotState(entry.name);
|
||||
}
|
||||
return { state: "active", workspaces: active.revisions.length };
|
||||
} catch (error) {
|
||||
throw workspaceError(error);
|
||||
try {
|
||||
const before = await this.storedStateFingerprint();
|
||||
const rootEntries = await readdir(this.repository.root, { withFileTypes: true });
|
||||
if (rootEntries.length === 0) {
|
||||
const after = await this.storedStateFingerprint();
|
||||
if (after !== before) throw new Error("workspace registry changed during inspection");
|
||||
return { state: "uninitialized", workspaces: 0, fingerprint: `sha256:${before}` };
|
||||
}
|
||||
});
|
||||
this.assertExactDirectoryEntries(rootEntries, {
|
||||
locks: "directory", repo: "directory", snapshots: "directory", state: "directory",
|
||||
});
|
||||
|
||||
this.assertExactDirectoryEntries(
|
||||
await readdir(this.repository.locksPath, { withFileTypes: true }),
|
||||
{ "empty-hooks": "directory", "repository.lock": "file" },
|
||||
);
|
||||
this.assertExactDirectoryEntries(
|
||||
await readdir(join(this.repository.locksPath, "empty-hooks"), { withFileTypes: true }),
|
||||
{},
|
||||
);
|
||||
|
||||
const stateEntries = await readdir(this.repository.statePath, { withFileTypes: true });
|
||||
const stateShape: Record<string, "file" | "directory"> = { "active.json": "file" };
|
||||
if (stateEntries.some((entry) => entry.name === "revision-leases")) {
|
||||
stateShape["revision-leases"] = "directory";
|
||||
}
|
||||
this.assertExactDirectoryEntries(stateEntries, stateShape);
|
||||
if (stateShape["revision-leases"] !== undefined) {
|
||||
this.assertExactDirectoryEntries(
|
||||
await readdir(join(this.repository.statePath, "revision-leases"), { withFileTypes: true }),
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
const active = this.decodeActiveState(JSON.parse(await readFile(
|
||||
join(this.repository.statePath, "active.json"), "utf8",
|
||||
)));
|
||||
const snapshotEntries = await readdir(this.repository.snapshotsPath, { withFileTypes: true });
|
||||
if (snapshotEntries.length === 0) throw new Error("workspace snapshots are unavailable");
|
||||
let activeSnapshotFound = false;
|
||||
for (const entry of snapshotEntries) {
|
||||
if (entry.name === "runtime") {
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("workspace runtime path is invalid");
|
||||
this.assertExactDirectoryEntries(
|
||||
await readdir(join(this.repository.snapshotsPath, "runtime"), { withFileTypes: true }),
|
||||
{},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!/^[0-9a-f]{40}$/.test(entry.name) || !entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
throw new Error("workspace snapshot path is invalid");
|
||||
}
|
||||
const state = entry.name === active.head ? active : await this.readStoredSnapshotState(entry.name);
|
||||
await this.assertSnapshotIntegrity(state, false, true);
|
||||
if (entry.name === active.head) activeSnapshotFound = true;
|
||||
}
|
||||
if (!activeSnapshotFound) throw new Error("active workspace snapshot is unavailable");
|
||||
|
||||
const after = await this.storedStateFingerprint();
|
||||
if (after !== before) throw new Error("workspace registry changed during inspection");
|
||||
return { state: "active", workspaces: active.revisions.length, fingerprint: `sha256:${before}` };
|
||||
} catch (error) {
|
||||
throw workspaceError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private assertExactDirectoryEntries(
|
||||
entries: Array<{ name: string; isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean }>,
|
||||
expected: Record<string, "file" | "directory">,
|
||||
): void {
|
||||
if (entries.length !== Object.keys(expected).length) throw new Error("workspace registry shape is invalid");
|
||||
for (const entry of entries) {
|
||||
const kind = expected[entry.name];
|
||||
if (kind === undefined || entry.isSymbolicLink()
|
||||
|| (kind === "file" && !entry.isFile())
|
||||
|| (kind === "directory" && !entry.isDirectory())) {
|
||||
throw new Error("workspace registry shape is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async storedStateFingerprint(): Promise<string> {
|
||||
const records: string[] = [];
|
||||
let entries = 0;
|
||||
let totalBytes = 0;
|
||||
const visit = async (path: string, relative: string): Promise<void> => {
|
||||
const before = lstatSync(path);
|
||||
if (before.isSymbolicLink()) throw new Error("workspace registry link is invalid");
|
||||
const metadata = [
|
||||
before.dev, before.ino, before.mode, before.uid, before.gid,
|
||||
before.size, before.mtimeMs, before.ctimeMs,
|
||||
].join(":");
|
||||
entries += 1;
|
||||
if (entries > 65_536) throw new Error("workspace registry contains too many entries");
|
||||
if (before.isDirectory()) {
|
||||
records.push(`directory:${relative}:${metadata}`);
|
||||
const children = await readdir(path);
|
||||
children.sort();
|
||||
for (const name of children) {
|
||||
await visit(join(path, name), relative === "." ? name : `${relative}/${name}`);
|
||||
}
|
||||
} else if (before.isFile()) {
|
||||
if (before.size > 256 * 1024 * 1024) throw new Error("workspace registry file is too large");
|
||||
totalBytes += before.size;
|
||||
if (totalBytes > 2 * 1024 * 1024 * 1024) throw new Error("workspace registry is too large");
|
||||
const contents = await readFile(path);
|
||||
records.push(`file:${relative}:${metadata}:${contents.length}:${digest(contents)}`);
|
||||
} else {
|
||||
throw new Error("workspace registry entry is invalid");
|
||||
}
|
||||
const after = lstatSync(path);
|
||||
if (before.dev !== after.dev || before.ino !== after.ino || before.mode !== after.mode
|
||||
|| before.uid !== after.uid || before.gid !== after.gid || before.size !== after.size
|
||||
|| before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) {
|
||||
throw new Error("workspace registry changed during inspection");
|
||||
}
|
||||
};
|
||||
await visit(this.repository.root, ".");
|
||||
return digest(records.join("\n"));
|
||||
}
|
||||
|
||||
private async readStoredSnapshotState(head: string): Promise<ActiveState> {
|
||||
return this.decodeSnapshotManifest(await this.readSnapshotManifest(safeCommit(head)));
|
||||
}
|
||||
|
||||
async read(id: string): Promise<{ workspace: WorkspaceDescriptor; revision: WorkspaceRevision }> {
|
||||
@@ -696,7 +791,11 @@ export class WorkspaceRegistry {
|
||||
return state;
|
||||
}
|
||||
|
||||
private async assertSnapshotIntegrity(state: ActiveState): Promise<void> {
|
||||
private async assertSnapshotIntegrity(
|
||||
state: ActiveState,
|
||||
verifyGitEvidence = true,
|
||||
exactStoredShape = false,
|
||||
): Promise<void> {
|
||||
const directory = join(this.repository.snapshotsPath, state.head);
|
||||
try {
|
||||
const manifest = this.decodeSnapshotManifest(await this.readSnapshotManifest(state.head));
|
||||
@@ -705,13 +804,33 @@ export class WorkspaceRegistry {
|
||||
throw new Error("manifest state does not match active state");
|
||||
}
|
||||
await this.assertManifestFiles(directory, manifest.files, this.expectedSnapshotFiles(state, directory));
|
||||
await this.assertSnapshotEvidenceContexts(state);
|
||||
if (exactStoredShape) this.assertStoredSnapshotShape(directory, state);
|
||||
if (verifyGitEvidence) await this.assertSnapshotEvidenceContexts(state);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceRegistryError) throw error;
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace snapshot integrity check failed");
|
||||
}
|
||||
}
|
||||
|
||||
private assertStoredSnapshotShape(directory: string, state: ActiveState): void {
|
||||
const expected: Record<string, "file" | "directory"> = { "snapshot.json": "file" };
|
||||
for (const revision of state.revisions) {
|
||||
expected[`${revision.id}.yaml`] = "file";
|
||||
expected[`${revision.id}.env.example`] = "file";
|
||||
expected[`${revision.id}.md`] = "file";
|
||||
const workspace = parseWorkspaceYaml(readFileSync(join(directory, `${revision.id}.yaml`), "utf8"));
|
||||
if (workspace.evidence?.source.type === "filesystem") expected[revision.id] = "directory";
|
||||
}
|
||||
this.assertExactDirectoryEntries(readdirSync(directory, { withFileTypes: true }), expected);
|
||||
for (const revision of state.revisions) {
|
||||
if (expected[revision.id] !== "directory") continue;
|
||||
this.assertExactDirectoryEntries(
|
||||
readdirSync(join(directory, revision.id), { withFileTypes: true }),
|
||||
{ evidence: "directory", "evidence.manifest.json": "file" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private expectedSnapshotFiles(state: ActiveState, directory: string): string[] {
|
||||
return state.revisions.flatMap((revision) => {
|
||||
const names = [`${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`];
|
||||
@@ -784,20 +903,6 @@ export class WorkspaceRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
private storedPathExists(path: string): boolean {
|
||||
try {
|
||||
const entry = lstatSync(path);
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace registry path is invalid");
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
||||
if (error instanceof WorkspaceRegistryError) throw error;
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace registry path is unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
private pathIsMissing(path: string): boolean {
|
||||
try {
|
||||
lstatSync(path);
|
||||
|
||||
Reference in New Issue
Block a user