import { spawnSync, type SpawnSyncOptionsWithStringEncoding } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import * as age from "age-encryption"; import { createRequire } from "node:module"; import { afterAll, describe, expect, it } from "vitest"; import { parseIdentityFile } from "./restore.js"; import { encryptBundleStream, drainToBuffer } from "./bundle.js"; import type { BundleManifest } from "./archive-index.js"; // The CLI runs as a subprocess so its process.exitCode handling is exercised. const CLI = join(__dirname, "restore.ts"); const RUN_OPTS: SpawnSyncOptionsWithStringEncoding = { encoding: "utf8", timeout: 60_000 }; // tsx does not export its CLI path; walk up from the resolved package.json // (pnpm symlinks point into the store, so dist/ sits beside package.json). const RUNNER = join( dirname(createRequire(import.meta.url).resolve("tsx/package.json")), "dist", "cli.mjs", ); const workDirs: string[] = []; afterAll(async () => { await Promise.all(workDirs.map((d) => rm(d, { recursive: true, force: true }).catch(() => undefined))); }); describe("parseIdentityFile", () => { it("reads one AGE-SECRET-KEY per line, ignoring comments and blanks", () => { const identity = "AGE-SECRET-KEY-1TESTKEYVALUENOTAREALKEY0000000"; expect(parseIdentityFile(`# admin key\n\n${identity}\n`)).toEqual([identity]); expect(parseIdentityFile("")).toEqual([]); }); }); describe("restore CLI (end-to-end)", () => { it("round-trips a real bundle: encrypt → decrypt → verify → write", async () => { const identity = await age.generateIdentity(); const recipient = await age.identityToRecipient(identity); const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 4, 5, 6]); const pdf = new TextEncoder().encode("%PDF-1.7\nrestore me"); const manifest: BundleManifest = { format: "chat-media-archive-bundle", version: 1, roomId: "!room:example.org", workspaceId: "w", groupId: "g", exportedAt: "2026-01-01T00:00:00.000Z", objectKey: null, recipients: [recipient], fileCount: 2, totalBytes: png.byteLength + pdf.byteLength, files: [ { eventId: "$e1", mxcUri: "mxc://example.org/a", originalName: "cat.png", safeName: "cat.png", mime: "image/png", mimeClass: "image", byteSize: png.byteLength, sha256: createHash("sha256").update(png).digest("hex"), sender: "@alice:example.org", createdAt: "2026-01-02T00:00:00.000Z", ocisResourceId: "ocis-1", available: true, }, { eventId: "$e2", mxcUri: "mxc://example.org/b", originalName: "doc.pdf", safeName: "doc.pdf", mime: "application/pdf", mimeClass: "document", byteSize: pdf.byteLength, sha256: createHash("sha256").update(pdf).digest("hex"), sender: "@bob:example.org", createdAt: "2026-01-03T00:00:00.000Z", ocisResourceId: "ocis-2", available: true, }, ], }; const dir = await mkdtemp(join(tmpdir(), "restore-cli-")); workDirs.push(dir); const bundlePath = join(dir, "bundle.tar.zst.age"); const identityPath = join(dir, "identity.txt"); const outDir = join(dir, "out"); await writeFile(identityPath, `# admin\n${identity}\n`); const ciphertext = await drainToBuffer(await encryptBundleStream( { manifest, payloads: new Map([["files/$e1/cat.png", png], ["files/$e2/doc.pdf", pdf]]) }, [recipient], )); await writeFile(bundlePath, ciphertext); // sanity: the bundle is ciphertext, not a readable tar expect(Buffer.from(ciphertext.subarray(257, 262)).toString("latin1")).not.toBe("ustar"); const run = spawnSync(process.execPath, [RUNNER, CLI, "--bundle", bundlePath, "--identity", identityPath, "--out", outDir], RUN_OPTS); expect(run.stderr).toBe(""); expect(run.stdout).toContain("2/2 files verified"); expect(run.status).toBe(0); const restoredPng = await readFile(join(outDir, "files", "$e1", "cat.png")); expect(new Uint8Array(restoredPng)).toEqual(png); const restoredPdf = await readFile(join(outDir, "files", "$e2", "doc.pdf")); expect(new Uint8Array(restoredPdf)).toEqual(pdf); const manifestOnDisk = JSON.parse(await readFile(join(outDir, "manifest.json"), "utf8")) as BundleManifest; expect(manifestOnDisk.files).toHaveLength(2); }, 90_000); it("fails (non-zero exit) when a payload's sha256 does not match the manifest", async () => { const identity = await age.generateIdentity(); const recipient = await age.identityToRecipient(identity); const good = new TextEncoder().encode("good bytes"); const manifest: BundleManifest = { format: "chat-media-archive-bundle", version: 1, roomId: "!r:x", workspaceId: "w", groupId: "g", exportedAt: new Date(0).toISOString(), objectKey: null, recipients: [], fileCount: 1, totalBytes: good.byteLength, files: [{ eventId: "$e1", mxcUri: "", originalName: "a.txt", safeName: "a.txt", mime: "text/plain", mimeClass: "document", byteSize: good.byteLength, sha256: createHash("sha256").update("tampered!").digest("hex"), sender: "@a:x", createdAt: new Date(0).toISOString(), ocisResourceId: "r", available: true, }], }; const dir = await mkdtemp(join(tmpdir(), "restore-bad-")); workDirs.push(dir); const bundlePath = join(dir, "b.tar.zst.age"); const identityPath = join(dir, "id.txt"); await writeFile(identityPath, identity); await writeFile(bundlePath, await drainToBuffer(await encryptBundleStream( { manifest, payloads: new Map([["files/$e1/a.txt", good]]) }, [recipient], ))); const run = spawnSync(process.execPath, [RUNNER, CLI, "--bundle", bundlePath, "--identity", identityPath, "--out", join(dir, "out")], RUN_OPTS); expect(run.status).toBe(1); expect(run.stdout).toContain("failed"); }, 90_000); });