164 lines
7.5 KiB
JavaScript
164 lines
7.5 KiB
JavaScript
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 }));
|