Phase B: encrypted archive export bundles (age), power-level-gated export command, encryption-state linter
This commit is contained in:
parent
a7ad1f059f
commit
699b9412c1
22 changed files with 2325 additions and 9 deletions
113
apps/archive-bot/src/r2-export.test.ts
Normal file
113
apps/archive-bot/src/r2-export.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import * as zlib from "node:zlib";
|
||||
import { promisify } from "node:util";
|
||||
import { R2ExportStore, exportObjectKey, R2ExportError } from "./r2-export.js";
|
||||
|
||||
/** Local S3-compatible stand-in: captures PUT headers/body, never dials out. */
|
||||
function s3Stub(put: { key: string; length: number; body: Buffer }): Server {
|
||||
const server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (c: Buffer) => chunks.push(c));
|
||||
req.on("end", () => {
|
||||
put.key = req.url ?? "";
|
||||
put.body = Buffer.concat(chunks);
|
||||
put.length = Number(req.headers["content-length"] ?? "-1");
|
||||
res.writeHead(200).end("{}");
|
||||
});
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
return server;
|
||||
}
|
||||
|
||||
let cleanup: (() => Promise<void>)[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await Promise.all(cleanup.map((fn) => fn().catch(() => undefined)));
|
||||
cleanup = [];
|
||||
});
|
||||
|
||||
function store(dir: string, port: number): R2ExportStore {
|
||||
return new R2ExportStore({
|
||||
endpoint: `http://127.0.0.1:${port}`,
|
||||
region: "auto",
|
||||
accessKeyId: "test",
|
||||
secretAccessKey: "test",
|
||||
bucket: "hub-matrix-media",
|
||||
spillDir: dir,
|
||||
allowInsecureEndpoint: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe("exportObjectKey", () => {
|
||||
it("builds exports/<room>/<timestamp>.tar.zst.age", () => {
|
||||
const key = exportObjectKey("!room:example.org", new Date("2026-01-02T03:04:05.678Z"));
|
||||
expect(key).toBe("exports/!room:example.org/20260102T030405Z.tar.zst.age");
|
||||
});
|
||||
});
|
||||
|
||||
describe("R2ExportStore", () => {
|
||||
it("rejects non-https endpoints", () => {
|
||||
expect(() => new R2ExportStore({
|
||||
endpoint: "http://r2.example", region: "auto", accessKeyId: "k", secretAccessKey: "s",
|
||||
})).toThrow(R2ExportError);
|
||||
expect(() => new R2ExportStore({
|
||||
endpoint: "not a url", region: "auto", accessKeyId: "k", secretAccessKey: "s",
|
||||
})).toThrow(R2ExportError);
|
||||
});
|
||||
|
||||
it("PUTs ciphertext with an explicit content-length and cleans the spill file", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "r2-test-"));
|
||||
cleanup.push(() => rm(dir, { recursive: true, force: true }));
|
||||
const put: { key: string; length: number; body: Buffer } = { key: "", length: -1, body: Buffer.alloc(0) };
|
||||
const server = s3Stub(put);
|
||||
await new Promise<void>((r) => server.once("listening", r));
|
||||
cleanup.push(async () => await new Promise<void>((r) => server.close(() => r())));
|
||||
|
||||
const payload = Buffer.from("ciphertext-payload".repeat(100));
|
||||
await store(dir, (server.address() as { port: number }).port).putBundle(
|
||||
"exports/!room:x/a.tar.zst.age",
|
||||
new Blob([payload]).stream() as ReadableStream<Uint8Array>,
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(put.key.startsWith("/hub-matrix-media/exports/")).toBe(true);
|
||||
expect(put.length).toBe(payload.byteLength);
|
||||
expect(put.body.byteLength).toBe(payload.byteLength);
|
||||
expect(put.body).toEqual(payload);
|
||||
// spill directory empty again — no ciphertext residue
|
||||
expect(await readdir(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves no spill file when the PUT fails", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "r2-test-"));
|
||||
cleanup.push(() => rm(dir, { recursive: true, force: true }));
|
||||
const server = createServer((req, res) => {
|
||||
void req;
|
||||
res.writeHead(503).end("unavailable");
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((r) => server.once("listening", r));
|
||||
cleanup.push(async () => await new Promise<void>((r) => server.close(() => r())));
|
||||
const s = store(dir, (server.address() as { port: number }).port);
|
||||
await expect(s.putBundle(
|
||||
"k", new Blob([Buffer.alloc(10)]).stream() as ReadableStream<Uint8Array>, new AbortController().signal,
|
||||
)).rejects.toThrow(R2ExportError);
|
||||
expect(await readdir(dir)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zstd sanity", () => {
|
||||
it("node zlib round-trips zstd frames produced for bundles", async () => {
|
||||
const input = Buffer.from("abcabcabc".repeat(100));
|
||||
const compressed = await promisify(zlib.zstdCompress)(input);
|
||||
const restored = await promisify(zlib.zstdDecompress)(compressed);
|
||||
expect(restored).toEqual(input);
|
||||
expect(createHash("sha256").update(restored).digest("hex")).toBe(createHash("sha256").update(input).digest("hex"));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue