T-HUB/apps/archive-bot/src/restore.ts

105 lines
4.5 KiB
JavaScript

#!/usr/bin/env node
/**
* Standalone phase-B restore tool.
*
* node dist/restore.js --bundle <path.tar.zst.age> --identity <age-identity-file> --out <dir>
*
* Decrypts an archive bundle with an age identity (AGE-SECRET-KEY-1…, one per
* line; the private key never touches the archive bot), untars it, verifies
* every file's sha256 against manifest.json and writes the verified tree.
* Exit code 0 only when every file matched.
*/
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import process from "node:process";
import { Readable } from "node:stream";
import { ageDecryptStream, drainToBuffer, readTarZstd } from "./bundle.js";
import { MANIFEST_PATH, type BundleManifest } from "./archive-index.js";
interface Args {
bundle: string;
identity: string;
out: string;
}
function parseArgs(argv: string[]): Args {
const args: Partial<Args> = {};
for (let i = 2; i < argv.length; i += 2) {
const flag = argv[i];
const value = argv[i + 1];
if (value === undefined) throw new Error(`missing value for ${flag}`);
if (flag === "--bundle") args.bundle = value;
else if (flag === "--identity") args.identity = value;
else if (flag === "--out") args.out = value;
else throw new Error(`unknown flag ${flag}`);
}
if (args.bundle === undefined) throw new Error("--bundle <file> is required");
if (args.identity === undefined) throw new Error("--identity <file> is required");
args.out ??= "restored-archive";
return args as Args;
}
/** Parse identities: `#` comments and blank lines ignored, one key per line. */
export function parseIdentityFile(text: string): string[] {
return text
.split("\n")
.map((line) => line.trim())
.filter((line) => line !== "" && !line.startsWith("#"));
}
async function main(): Promise<void> {
const args = parseArgs(process.argv);
const identities = parseIdentityFile(await readFile(args.identity, "utf8"));
if (identities.length === 0) throw new Error("identity file contains no AGE-SECRET-KEY-1… keys");
const ciphertext = Readable.toWeb(Readable.from(await readFile(args.bundle))) as ReadableStream<Uint8Array>;
const plain = await ageDecryptStream(ciphertext, identities);
const root = resolve(args.out);
let manifest: BundleManifest | null = null;
let verified = 0;
let failures = 0;
let unexpected = 0;
const manifestPathFor = (entry: { eventId: string; safeName: string }): string =>
`${"files"}/${entry.eventId}/${entry.safeName}`;
for await (const entry of readTarZstd(plain)) {
if (entry.name === MANIFEST_PATH) {
const bytes = await drainToBuffer(entry.stream);
manifest = JSON.parse(new TextDecoder().decode(bytes)) as BundleManifest;
await mkdir(join(root, MANIFEST_PATH, ".."), { recursive: true });
await writeFile(join(root, MANIFEST_PATH), bytes, { mode: 0o600 });
continue;
}
if (manifest === null) {
// manifest.json must precede payloads in the bundle; fail loudly otherwise.
throw new Error(`payload ${entry.name} appeared before manifest.json`);
}
if (entry.name === MANIFEST_PATH) continue; // handled above
const bytes = await drainToBuffer(entry.stream);
const digest = createHash("sha256").update(bytes).digest("hex");
const record = manifest.files.find((f) => manifestPathFor(f) === entry.name);
const target = join(root, entry.name);
await mkdir(dirname(target), { recursive: true });
await writeFile(target, bytes, { mode: 0o600 });
if (record === undefined) {
unexpected += 1;
console.error(`restore: ${entry.name} is not in the manifest`);
} else if (record.sha256 === digest) {
verified += 1;
} else {
failures += 1;
console.error(`restore: sha256 mismatch for ${entry.name}: expected ${record.sha256}, got ${digest}`);
}
}
if (manifest === null) throw new Error("bundle contains no manifest.json");
const expected = manifest.files.filter((f) => f.available).length;
console.log(`restore: ${verified}/${expected} files verified${failures > 0 ? `, ${failures} failed` : ""}${unexpected > 0 ? `, ${unexpected} unexpected` : ""}`);
console.log(`restore: room ${manifest.roomId} exported ${manifest.exportedAt}`);
if (verified !== expected || failures > 0 || unexpected > 0) process.exitCode = 1;
}
void main().catch((error: unknown) => {
console.error(`restore: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
});