import assert from "node:assert/strict"; import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import test from "node:test"; import ts from "typescript"; const root = path.resolve(import.meta.dirname, "../../.."); const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-archive-cancellation-unit-")); const sourcePath = path.join(root, "web/protocol/archive-extraction-transaction.ts"); const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true, }); assert.deepEqual(transpiled.diagnostics, []); fs.writeFileSync(path.join(temporary, "archive-extraction-transaction.mjs"), transpiled.outputText); const extraction = await import(pathToFileURL(path.join(temporary, "archive-extraction-transaction.mjs"))); const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-04H/manifest.json"), "utf8")); const fileSha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex"); const bytesSha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); class DirectoryExtractionStorage { constructor(directory, committed) { this.directory = directory; this.committed = committed; fs.mkdirSync(path.join(directory, "committed"), { recursive: true }); fs.writeFileSync(path.join(directory, "committed", "project.blend"), Buffer.from("old-project")); } async readCommitted() { return { ...this.committed }; } async createStaging(transactionId) { fs.mkdirSync(path.join(this.directory, "staging", transactionId), { recursive: true }); } async writeStaging(transactionId, entryPath, bytes) { const target = path.join(this.directory, "staging", transactionId, entryPath); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, bytes); } async countStagingEntries(transactionId) { const staging = path.join(this.directory, "staging", transactionId); if (!fs.existsSync(staging)) return 0; const visit = (directory) => fs.readdirSync(directory, { withFileTypes: true }).reduce( (count, entry) => count + (entry.isDirectory() ? visit(path.join(directory, entry.name)) : 1), 0, ); return visit(staging); } async discardStaging(transactionId) { const count = await this.countStagingEntries(transactionId); fs.rmSync(path.join(this.directory, "staging", transactionId), { recursive: true, force: true }); return count; } async commitStaging(transactionId, expected, candidate) { assert.deepEqual(this.committed, expected); const source = path.join(this.directory, "staging", transactionId, "project.blend"); const target = path.join(this.directory, "committed", "project.blend"); fs.renameSync(source, target); await this.discardStaging(transactionId); this.committed = { ...candidate }; return { ...this.committed }; } } const oldBytes = Buffer.from("old-project"); const newBytes = Buffer.from("new-project"); const metadataBytes = Buffer.from("metadata"); const committed = { projectId: "project:m12-04h", revision: 8, sha256: bytesSha256(oldBytes) }; const candidate = { projectId: committed.projectId, revision: 9, sha256: bytesSha256(newBytes) }; const request = { schemaVersion: 1, transactionId: "transaction:m12-04h", archiveId: "archive:m12-04h", committed, candidate, entries: [ { path: "metadata/index.json", uncompressedBytes: metadataBytes.byteLength, sha256: bytesSha256(metadataBytes) }, { path: "project.blend", uncompressedBytes: newBytes.byteLength, sha256: bytesSha256(newBytes) }, ], }; function createStorage(name) { const directory = path.join(temporary, name); fs.mkdirSync(directory, { recursive: true }); return new DirectoryExtractionStorage(directory, committed); } test("M12-04H binds cancellation rollback and evidence artifacts", () => { assert.equal(manifest.task, "M12-04H"); assert.equal(manifest.parentTask, "M12-04G"); assert.equal(manifest.nextTask, "M12-04I"); for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(artifact.path), artifact.sha256, artifact.path); }); test("M12-04H removes partially written staging and preserves the committed project on cancellation", async () => { const storage = createStorage("cancel-after-write"); const controller = new AbortController(); let reads = 0; const receipt = await extraction.runArchiveExtractionTransaction(request, storage, async (entry) => { reads++; if (reads === 2) controller.abort(); return entry.path === "project.blend" ? newBytes : metadataBytes; }, controller.signal); assert.deepEqual(receipt, { status: "CANCELLED", code: "IO_ARCHIVE_CANCELLED", transactionId: request.transactionId, committedBefore: committed, committedAfter: committed, removedStagingEntries: 1, stagingEntriesAfter: 0, publishedProjects: 0, }); assert.equal(fs.readFileSync(path.join(storage.directory, "committed", "project.blend"), "utf8"), "old-project"); assert.equal(await storage.countStagingEntries(request.transactionId), 0); }); test("M12-04H cancels before staging and after the last staged write without publishing", async () => { const beforeStorage = createStorage("cancel-before-stage"); const beforeController = new AbortController(); beforeController.abort(); const before = await extraction.runArchiveExtractionTransaction(request, beforeStorage, async () => newBytes, beforeController.signal); assert.equal(before.status, "CANCELLED"); assert.equal(before.removedStagingEntries, 0); const finalStorage = createStorage("cancel-after-last-write"); const finalController = new AbortController(); const originalWrite = finalStorage.writeStaging.bind(finalStorage); finalStorage.writeStaging = async (transactionId, entryPath, bytes) => { await originalWrite(transactionId, entryPath, bytes); if (entryPath === "project.blend") finalController.abort(); }; const after = await extraction.runArchiveExtractionTransaction(request, finalStorage, async (entry) => entry.path === "project.blend" ? newBytes : metadataBytes, finalController.signal); assert.equal(after.status, "CANCELLED"); assert.equal(after.removedStagingEntries, 2); assert.deepEqual(await finalStorage.readCommitted(), committed); assert.equal(fs.readFileSync(path.join(finalStorage.directory, "committed", "project.blend"), "utf8"), "old-project"); }); test("M12-04H commits only after all staged payloads pass identity checks", async () => { const storage = createStorage("commit"); const receipt = await extraction.runArchiveExtractionTransaction(request, storage, async (entry) => entry.path === "project.blend" ? newBytes : metadataBytes, new AbortController().signal); assert.deepEqual(receipt, { status: "COMMITTED", transactionId: request.transactionId, committed: candidate, stagingEntriesAfter: 0, }); assert.equal(fs.readFileSync(path.join(storage.directory, "committed", "project.blend"), "utf8"), "new-project"); }); test("M12-04H cleans staging on payload failure and detects rollback identity drift", async () => { const failedStorage = createStorage("payload-failure"); await assert.rejects( extraction.runArchiveExtractionTransaction(request, failedStorage, async () => Buffer.from("wrong"), new AbortController().signal), { code: "ASSET_SOURCE_HASH_MISMATCH" }, ); assert.equal(await failedStorage.countStagingEntries(request.transactionId), 0); assert.deepEqual(await failedStorage.readCommitted(), committed); const driftStorage = createStorage("rollback-drift"); const originalDiscard = driftStorage.discardStaging.bind(driftStorage); driftStorage.discardStaging = async (transactionId) => { const removed = await originalDiscard(transactionId); driftStorage.committed = { ...committed, revision: committed.revision + 1 }; return removed; }; const controller = new AbortController(); await assert.rejects( extraction.runArchiveExtractionTransaction(request, driftStorage, async (entry) => { const bytes = entry.path === "project.blend" ? newBytes : metadataBytes; queueMicrotask(() => controller.abort()); return bytes; }, controller.signal), { code: "STORAGE_TRANSACTION" }, ); }); test("M12-04H rejects stale commits, non-canonical paths, prefix conflicts, and undeclared fields", async () => { const storage = createStorage("invalid-requests"); await assert.rejects( extraction.runArchiveExtractionTransaction({ ...request, committed: { ...committed, revision: 7 } }, storage, async () => newBytes, new AbortController().signal), { code: "REVISION_CONFLICT" }, ); for (const unsafePath of ["../project.blend", "C:/project.blend", "https:project.blend", "dir\\project.blend"]) { await assert.rejects( extraction.runArchiveExtractionTransaction({ ...request, entries: [{ ...request.entries[0], path: unsafePath }] }, storage, async () => metadataBytes, new AbortController().signal), { code: "IO_ARCHIVE_UNSAFE" }, ); } await assert.rejects( extraction.runArchiveExtractionTransaction({ ...request, entries: [request.entries[0], { ...request.entries[1], path: "metadata" }] }, storage, async () => metadataBytes, new AbortController().signal), { code: "IO_ARCHIVE_UNSAFE" }, ); await assert.rejects( extraction.runArchiveExtractionTransaction({ ...request, future: true }, storage, async () => newBytes, new AbortController().signal), { code: "IO_ARCHIVE_UNSAFE" }, ); }); test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));