diff --git a/backend/src/workspaces/registry.ts b/backend/src/workspaces/registry.ts index aa8747cc..1e64aca1 100644 --- a/backend/src/workspaces/registry.ts +++ b/backend/src/workspaces/registry.ts @@ -42,6 +42,17 @@ interface SnapshotManifest extends ActiveState { files: Record; } +type LegacyWorkspaceRevision = Omit; + +interface LegacyActiveState { + head: string; + revisions: LegacyWorkspaceRevision[]; +} + +interface LegacySnapshotManifest extends LegacyActiveState { + files: Record; +} + function workspacePath(id: string): string { if (!/^[a-z][a-z0-9-]{2,62}$/.test(id)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace ID is invalid"); @@ -185,7 +196,7 @@ export class WorkspaceRegistry { state: snapshot.state, })); if (this.pathExists(snapshotDirectory)) { - await this.assertSnapshotIntegrity({ head: safeHead, revisions }); + await this.assertOrMigrateSnapshotIntegrity({ head: safeHead, revisions }); } else { const staging = join(this.repository.snapshotsPath, `.staging-${randomUUID()}`); await mkdir(staging, { mode: 0o700 }); @@ -242,7 +253,11 @@ export class WorkspaceRegistry { private async tryActiveState(): Promise { const file = join(this.repository.statePath, "active.json"); try { - const state = JSON.parse(await readFile(file, "utf8")) as ActiveState; + const parsed: unknown = JSON.parse(await readFile(file, "utf8")); + if (this.isLegacyActiveState(parsed)) { + return await this.migrateLegacyActiveState(parsed); + } + const state = parsed as ActiveState; this.assertActiveState(state); await this.assertSnapshotIntegrity(state); return state; @@ -260,6 +275,13 @@ export class WorkspaceRegistry { await rename(staging, target); } + private async writeSnapshotManifest(directory: string, manifest: SnapshotManifest): Promise { + const target = join(directory, "snapshot.json"); + const staging = join(directory, `.snapshot-${randomUUID()}.json`); + await writeFile(staging, JSON.stringify(manifest), { encoding: "utf8", mode: 0o400 }); + await rename(staging, target); + } + private assertActiveState(state: ActiveState): void { safeCommit(state.head); if (!Array.isArray(state.revisions) || state.revisions.length === 0) throw new Error("bad state"); @@ -277,38 +299,151 @@ export class WorkspaceRegistry { } } + private isLegacyActiveState(value: unknown): value is LegacyActiveState { + if (!value || typeof value !== "object") return false; + const revisions = (value as { revisions?: unknown }).revisions; + return Array.isArray(revisions) && revisions.length > 0 && revisions.every((revision) => ( + revision && typeof revision === "object" && !("state" in revision) + )); + } + + private isLegacySnapshotManifest(value: unknown): value is LegacySnapshotManifest { + return this.isLegacyActiveState(value) + && !!(value as { files?: unknown }).files + && typeof (value as { files?: unknown }).files === "object" + && !Array.isArray((value as { files?: unknown }).files); + } + + private assertLegacyActiveState(state: LegacyActiveState): void { + safeCommit(state.head); + if (!Array.isArray(state.revisions) || state.revisions.length === 0) throw new Error("bad legacy state"); + const ids = new Set(); + for (const revision of state.revisions) { + safeCommit(revision.commit); + safeBlob(revision.blob); + if (revision.commit !== state.head || ids.has(revision.id)) throw new Error("bad legacy revision"); + ids.add(revision.id); + workspacePath(revision.id); + if (!isAbsolute(revision.snapshotPath) || revision.snapshotPath !== this.snapshotPath(revision.commit, revision.id)) { + throw new Error("bad legacy snapshot path"); + } + } + } + + private async migrateLegacyActiveState(legacy: LegacyActiveState): Promise { + this.assertLegacyActiveState(legacy); + const state = await this.deriveStateFromLegacyRevisions(legacy); + const manifest = await this.readSnapshotManifest(state.head); + if (this.isLegacySnapshotManifest(manifest)) { + await this.migrateLegacySnapshotManifest(state, manifest); + } else { + await this.assertSnapshotIntegrity(state); + } + await this.writeActiveState(state); + return state; + } + + private async deriveStateFromLegacyRevisions(legacy: LegacyActiveState): Promise { + const revisions: WorkspaceRevision[] = []; + for (const revision of legacy.revisions) { + const source = await readFile(revision.snapshotPath, "utf8"); + const workspace = parseWorkspaceYaml(source); + if (workspace.workspace.id !== revision.id) throw new Error("legacy snapshot workspace is invalid"); + revisions.push({ + ...revision, + state: isCanonicalWorkspace(workspace) ? "operational" : "migration_required", + }); + } + return { head: legacy.head, revisions }; + } + + private async assertOrMigrateSnapshotIntegrity(state: ActiveState): Promise { + const manifest = await this.readSnapshotManifest(state.head); + if (this.isLegacySnapshotManifest(manifest)) { + await this.migrateLegacySnapshotManifest(state, manifest); + return; + } + await this.assertSnapshotIntegrity(state); + } + + private async migrateLegacySnapshotManifest( + state: ActiveState, + suppliedManifest?: LegacySnapshotManifest, + ): Promise { + const manifest = suppliedManifest ?? await this.readSnapshotManifest(state.head); + try { + if (!this.isLegacySnapshotManifest(manifest)) throw new Error("snapshot is not pre-state"); + this.assertLegacyActiveState(manifest); + if (manifest.head !== state.head || !this.sameLegacyRevisions(manifest.revisions, state.revisions)) { + throw new Error("legacy manifest revisions do not match active state"); + } + const derived = await this.deriveStateFromLegacyRevisions(manifest); + if (!this.sameRevisions(derived.revisions, state.revisions)) { + throw new Error("legacy manifest state does not match workspace snapshots"); + } + const directory = join(this.repository.snapshotsPath, state.head); + const legacyExpected = state.revisions.flatMap((revision) => [ + `${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`, + ]); + await this.assertManifestFiles(directory, manifest.files, legacyExpected); + const expected = this.expectedSnapshotFiles(state); + const files = Object.fromEntries(expected.map((name) => [name, manifest.files[name]])); + await this.writeSnapshotManifest(directory, { ...state, files }); + } catch (error) { + if (error instanceof WorkspaceRegistryError) throw error; + throw new WorkspaceRegistryError("workspace_invalid", "Workspace snapshot integrity check failed"); + } + } + + private async readSnapshotManifest(head: string): Promise { + const path = join(this.repository.snapshotsPath, head, "snapshot.json"); + return JSON.parse(await readFile(path, "utf8")); + } + private async assertSnapshotIntegrity(state: ActiveState): Promise { const directory = join(this.repository.snapshotsPath, state.head); - const manifestPath = join(directory, "snapshot.json"); try { - const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as SnapshotManifest; + const manifest = await this.readSnapshotManifest(state.head) as SnapshotManifest; this.assertActiveState(manifest); if (manifest.head !== state.head || !this.sameRevisions(manifest.revisions, state.revisions)) { throw new Error("manifest revisions do not match active state"); } - const expected = state.revisions.flatMap((revision) => revision.state === "operational" - ? [`${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`] - : [`${revision.id}.yaml`]); - if (Object.keys(manifest.files).length !== expected.length || !expected.every((name) => ( - /^[0-9a-f]{64}$/.test(manifest.files[name] ?? "") - ))) throw new Error("manifest files are invalid"); - for (const name of expected) { - const path = join(directory, name); - const entry = lstatSync(path); - if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("snapshot file is invalid"); - const contents = await readFile(path); - if (digest(contents) !== manifest.files[name]) throw new Error("snapshot file does not match manifest"); - if (name.endsWith(".yaml")) { - const workspace = parseWorkspaceYaml(contents.toString("utf8")); - if (workspace.workspace.id !== name.slice(0, -".yaml".length)) throw new Error("snapshot workspace is invalid"); - } - } + await this.assertManifestFiles(directory, manifest.files, this.expectedSnapshotFiles(state)); } catch (error) { if (error instanceof WorkspaceRegistryError) throw error; throw new WorkspaceRegistryError("workspace_invalid", "Workspace snapshot integrity check failed"); } } + private expectedSnapshotFiles(state: ActiveState): string[] { + return state.revisions.flatMap((revision) => revision.state === "operational" + ? [`${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`] + : [`${revision.id}.yaml`]); + } + + private async assertManifestFiles( + directory: string, + files: Record, + expected: string[], + ): Promise { + if (!files || typeof files !== "object" || Object.keys(files).length !== expected.length || !expected.every((name) => ( + /^[0-9a-f]{64}$/.test(files[name] ?? "") + ))) throw new Error("manifest files are invalid"); + for (const name of expected) { + const path = join(directory, name); + const entry = lstatSync(path); + if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("snapshot file is invalid"); + const contents = await readFile(path); + if (digest(contents) !== files[name]) throw new Error("snapshot file does not match manifest"); + if (name.endsWith(".yaml")) { + const workspace = parseWorkspaceYaml(contents.toString("utf8")); + if (workspace.workspace.id !== name.slice(0, -".yaml".length)) { + throw new Error("snapshot workspace is invalid"); + } + } + } + } + private sameRevisions(left: WorkspaceRevision[], right: WorkspaceRevision[]): boolean { return left.length === right.length && left.every((revision, index) => { const candidate = right[index]; @@ -319,6 +454,15 @@ export class WorkspaceRegistry { }); } + private sameLegacyRevisions(left: LegacyWorkspaceRevision[], right: WorkspaceRevision[]): boolean { + return left.length === right.length && left.every((revision, index) => { + const candidate = right[index]; + return candidate !== undefined + && candidate.id === revision.id && candidate.commit === revision.commit + && candidate.blob === revision.blob && candidate.snapshotPath === revision.snapshotPath; + }); + } + private pathExists(path: string): boolean { try { const entry = lstatSync(path); diff --git a/backend/test/workspace-registry.test.ts b/backend/test/workspace-registry.test.ts index 768f912c..99296b83 100644 --- a/backend/test/workspace-registry.test.ts +++ b/backend/test/workspace-registry.test.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync, } from "node:fs"; @@ -91,6 +92,35 @@ async function pushInvalidWorkspace(source: string): Promise { await git(source, ["push", "origin", "main"]); } +function legacyDigest(contents: string): string { + return createHash("sha256").update(contents).digest("hex"); +} + +function persistPreStateManifest(root: string, commit: string): void { + const snapshotDirectory = join(root, "snapshots", commit); + const activePath = join(root, "state", "active.json"); + const snapshotPath = join(snapshotDirectory, "snapshot.json"); + const active = JSON.parse(readFileSync(activePath, "utf8")); + const manifest = JSON.parse(readFileSync(snapshotPath, "utf8")); + const envName = "psd-clinical.env.example"; + const docsName = "psd-clinical.md"; + const envExample = "# Legacy registry artifact\n"; + const markdown = "# Legacy registry artifact\n"; + + writeFileSync(join(snapshotDirectory, envName), envExample); + writeFileSync(join(snapshotDirectory, docsName), markdown); + active.revisions = active.revisions.map(({ state: _state, ...revision }: Record) => revision); + manifest.revisions = manifest.revisions.map(({ state: _state, ...revision }: Record) => revision); + manifest.files = { + "psd-clinical.yaml": manifest.files["psd-clinical.yaml"], + [envName]: legacyDigest(envExample), + [docsName]: legacyDigest(markdown), + }; + writeFileSync(activePath, JSON.stringify(active)); + chmodSync(snapshotPath, 0o600); + writeFileSync(snapshotPath, JSON.stringify(manifest)); +} + test("bootstraps a checkout and activates a validated immutable snapshot", async () => { const remote = await fixture(); const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); @@ -123,6 +153,76 @@ test("lists a v1 descriptor in migration-required state without rendering operat expect(existsSync(join(remote.root, "registry", "snapshots", status.head!, "psd-clinical.md"))).toBe(false); }); +test("migrates a validated pre-state manifest and keeps its v1 workspace migration-gated", async () => { + const legacyYaml = validYaml.replace( + " database: postgres\n schema: vectors\n", + "", + ).replace("schema_version: 2", "schema_version: 1"); + const remote = await fixture(legacyYaml); + const root = join(remote.root, "registry"); + const firstRegistry = new WorkspaceRegistry(config(root, remote.remote)); + await firstRegistry.bootstrap(); + persistPreStateManifest(root, remote.initialCommit); + + const restoredRegistry = new WorkspaceRegistry(config(root, remote.remote)); + await expect(restoredRegistry.bootstrap()).resolves.toMatchObject({ + head: remote.initialCommit, + degraded: false, + }); + await expect(restoredRegistry.list()).resolves.toMatchObject([ + { id: "psd-clinical", state: "migration_required" }, + ]); + + const active = JSON.parse(readFileSync(join(root, "state", "active.json"), "utf8")); + const manifest = JSON.parse(readFileSync(join(root, "snapshots", remote.initialCommit, "snapshot.json"), "utf8")); + expect(active.revisions[0].state).toBe("migration_required"); + expect(manifest.revisions[0].state).toBe("migration_required"); + expect(Object.keys(manifest.files)).toEqual(["psd-clinical.yaml"]); +}); + +test("finishes a pre-state active manifest migration after its snapshot was atomically updated", async () => { + const legacyYaml = validYaml.replace( + " database: postgres\n schema: vectors\n", + "", + ).replace("schema_version: 2", "schema_version: 1"); + const remote = await fixture(legacyYaml); + const root = join(remote.root, "registry"); + const registry = new WorkspaceRegistry(config(root, remote.remote)); + await registry.bootstrap(); + persistPreStateManifest(root, remote.initialCommit); + + const snapshotPath = join(root, "snapshots", remote.initialCommit, "snapshot.json"); + const manifest = JSON.parse(readFileSync(snapshotPath, "utf8")); + manifest.revisions[0].state = "migration_required"; + manifest.files = { "psd-clinical.yaml": manifest.files["psd-clinical.yaml"] }; + writeFileSync(snapshotPath, JSON.stringify(manifest)); + + const restoredRegistry = new WorkspaceRegistry(config(root, remote.remote)); + await expect(restoredRegistry.list()).resolves.toMatchObject([ + { id: "psd-clinical", state: "migration_required" }, + ]); +}); + +test("rejects a corrupt pre-state manifest rather than accepting it during migration", async () => { + const legacyYaml = validYaml.replace( + " database: postgres\n schema: vectors\n", + "", + ).replace("schema_version: 2", "schema_version: 1"); + const remote = await fixture(legacyYaml); + const root = join(remote.root, "registry"); + const registry = new WorkspaceRegistry(config(root, remote.remote)); + await registry.bootstrap(); + persistPreStateManifest(root, remote.initialCommit); + const snapshotPath = join(root, "snapshots", remote.initialCommit, "snapshot.json"); + const manifest = JSON.parse(readFileSync(snapshotPath, "utf8")); + manifest.files["psd-clinical.yaml"] = "0".repeat(64); + writeFileSync(snapshotPath, JSON.stringify(manifest)); + + const restoredRegistry = new WorkspaceRegistry(config(root, remote.remote)); + await expect(restoredRegistry.bootstrap()).rejects.toMatchObject({ code: "workspace_invalid" }); + await expect(restoredRegistry.list()).rejects.toMatchObject({ code: "workspace_invalid" }); +}); + test("keeps the last valid snapshot when a pulled commit has invalid YAML", async () => { const remote = await fixture(); const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));