105 lines
4.5 KiB
JavaScript
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;
|
|
});
|