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-recovery-unit-")); for (const sourceName of ["archive-extraction-transaction.ts", "archive-extraction-recovery.ts"]) { const sourcePath = path.join(root, "web/protocol", sourceName); 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, sourceName.replace(".ts", ".mjs")), transpiled.outputText.replaceAll('from "./archive-extraction-transaction"', 'from "./archive-extraction-transaction.mjs"'), ); } const extraction = await import(pathToFileURL(path.join(temporary, "archive-extraction-recovery.mjs"))); const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-04I/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 FaultingDirectoryStorage { constructor(directory, committed) { this.directory = directory; this.committed = { ...committed }; this.fault = null; this.partialBytes = 0; 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 }); if (this.fault) { const partial = bytes.subarray(0, Math.max(1, Math.floor(bytes.byteLength / 2))); fs.writeFileSync(target, partial); this.partialBytes += partial.byteLength; const fault = this.fault; this.fault = null; throw fault; } 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 largeBytes = Buffer.alloc(4096, 0x51); const smallBytes = Buffer.from("small-project"); const committed = { projectId: "project:m12-04i", revision: 9, sha256: bytesSha256(oldBytes) }; function request(transactionId, base, bytes) { return { schemaVersion: 1, transactionId, archiveId: `archive:${transactionId}`, committed: base, candidate: { projectId: base.projectId, revision: base.revision + 1, sha256: bytesSha256(bytes) }, entries: [{ path: "project.blend", uncompressedBytes: bytes.byteLength, sha256: bytesSha256(bytes) }], }; } function createStorage(name) { const directory = path.join(temporary, name); fs.mkdirSync(directory, { recursive: true }); return new FaultingDirectoryStorage(directory, committed); } async function faultThenRecover(name, fault, expectedCode) { const storage = createStorage(name); storage.fault = fault; const failedRequest = request(`transaction:${name}:large`, committed, largeBytes); await assert.rejects( extraction.runArchiveExtractionTransaction(failedRequest, storage, async () => largeBytes, new AbortController().signal), { code: expectedCode }, ); assert.ok(storage.partialBytes > 0); assert.equal(await storage.countStagingEntries(failedRequest.transactionId), 0); assert.deepEqual(await storage.readCommitted(), committed); assert.equal(fs.readFileSync(path.join(storage.directory, "committed", "project.blend"), "utf8"), "old-project"); const recoveryRequest = request(`transaction:${name}:small`, committed, smallBytes); const receipt = await extraction.runArchiveExtractionTransaction( recoveryRequest, storage, async () => smallBytes, new AbortController().signal, ); assert.deepEqual(receipt, { status: "COMMITTED", transactionId: recoveryRequest.transactionId, committed: recoveryRequest.candidate, stagingEntriesAfter: 0, }); assert.equal(await storage.countStagingEntries(recoveryRequest.transactionId), 0); assert.equal(fs.readFileSync(path.join(storage.directory, "committed", "project.blend"), "utf8"), "small-project"); } test("M12-04I binds quota/OOM cleanup and recovery evidence", () => { assert.equal(manifest.task, "M12-04I"); assert.equal(manifest.parentTask, "M12-04H"); assert.equal(manifest.nextTask, "M12-04J"); assert.deepEqual(manifest.assertions.faultCodes, ["STORAGE_QUOTA", "WASM_OUT_OF_MEMORY"]); for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(artifact.path), artifact.sha256, artifact.path); }); test("M12-04I releases partial staging after quota and accepts a small archive in the same storage", async () => { await faultThenRecover("quota", new DOMException("quota exhausted", "QuotaExceededError"), "STORAGE_QUOTA"); }); test("M12-04I releases partial staging after OOM and accepts a small archive in the same storage", async () => { await faultThenRecover("oom", Object.assign(new Error("deterministic allocation failure"), { code: "WASM_OUT_OF_MEMORY" }), "WASM_OUT_OF_MEMORY"); }); test("M12-04I maps only declared resource faults", () => { assert.equal(extraction.archiveExtractionResourceFaultCode(new DOMException("full", "QuotaExceededError")), "STORAGE_QUOTA"); assert.equal(extraction.archiveExtractionResourceFaultCode(Object.assign(new Error("fault"), { code: "STORAGE_QUOTA" })), "STORAGE_QUOTA"); assert.equal(extraction.archiveExtractionResourceFaultCode(Object.assign(new Error("fault"), { code: "WASM_OUT_OF_MEMORY" })), "WASM_OUT_OF_MEMORY"); assert.equal(extraction.archiveExtractionResourceFaultCode(Object.assign(new Error("fault"), { name: "OutOfMemoryError" })), "WASM_OUT_OF_MEMORY"); assert.equal(extraction.archiveExtractionResourceFaultCode(new RangeError("array length is invalid")), undefined); assert.equal(extraction.archiveExtractionResourceFaultCode(new Error("ordinary IO failure")), undefined); }); test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));