diff --git a/src/lib/export/__tests__/backup-owner-rewrite.test.ts b/src/lib/export/__tests__/backup-owner-rewrite.test.ts new file mode 100644 index 000000000..f087f890d --- /dev/null +++ b/src/lib/export/__tests__/backup-owner-rewrite.test.ts @@ -0,0 +1,116 @@ +/** + * `rewriteBackupOwner` must change the top-level `userId` and nothing else, + * over any chunk boundaries: the output parses to the input with that one + * member replaced, and is byte-identical to it everywhere outside the value. + */ +import { describe, expect, it } from "vitest"; + +import { BackupJsonError } from "../backup-json-scan"; +import { + BackupOwnerRewriteError, + rewriteBackupOwner, +} from "../backup-owner-rewrite"; + +async function* chunked(text: string, sizes: number[]) { + const bytes = Buffer.from(text, "utf8"); + let at = 0; + let i = 0; + while (at < bytes.length) { + const size = sizes[i++ % sizes.length]!; + yield bytes.subarray(at, at + size); + at += size; + } +} + +async function run(text: string, target: string, sizes: number[] = [7]) { + const rewrite = rewriteBackupOwner(chunked(text, sizes), target); + const parts: Buffer[] = []; + for await (const part of rewrite.bytes) parts.push(part); + return { + text: Buffer.concat(parts).toString("utf8"), + declared: rewrite.declaredOwner(), + }; +} + +const NESTED = { + schemaVersion: "2", + exportedAt: "2026-10-01T00:00:00.000Z", + userId: "cerased0000000000000000", + measurements: [ + { id: "m1", userId: "cerased0000000000000000", note: 'a "userId": x' }, + ], + profile: { userId: "cerased0000000000000000", name: "ä ö ü 🫀 } ] , :" }, + notes: ["userId", "brackets ] } [ {"], +}; + +describe("rewriteBackupOwner", () => { + it("replaces only the top-level owner, at every chunk size", async () => { + const text = JSON.stringify(NESTED, null, 1); + for (const sizes of [[1], [2], [3], [5, 1, 9], [64], [text.length]]) { + const out = await run(text, "ctarget0000000000000000", sizes); + expect(out.declared).toBe("cerased0000000000000000"); + expect(JSON.parse(out.text)).toEqual({ + ...NESTED, + userId: "ctarget0000000000000000", + }); + // Outside the value, the bytes are the input's. + expect(out.text).toBe( + text.replace( + '"userId": "cerased0000000000000000"', + '"userId": "ctarget0000000000000000"', + ), + ); + } + }); + + it("reads an escaped spelling of the key as the key", async () => { + const text = '{"user\\u0049d":"cold","a":[1,{"userId":"keep"}]}'; + const out = await run(text, "cnew", [1]); + expect(out.declared).toBe("cold"); + expect(JSON.parse(out.text)).toEqual({ + userId: "cnew", + a: [1, { userId: "keep" }], + }); + }); + + it("replaces every top-level owner and declares the last, as JSON.parse keeps it", async () => { + const text = '{"userId":"first","x":1,"userId":"second"}'; + const out = await run(text, "cnew", [4]); + expect(out.declared).toBe(JSON.parse(text).userId); + expect(out.text).toBe('{"userId":"cnew","x":1,"userId":"cnew"}'); + }); + + it("leaves a file without an owner unchanged and declares none", async () => { + const text = '{"schemaVersion":"2","rows":[{"userId":"nested"}]}'; + const out = await run(text, "cnew", [3]); + expect(out.declared).toBeNull(); + expect(out.text).toBe(text); + }); + + it("leaves a value that is a string elsewhere, or not a string, alone", async () => { + const text = '{"name":"userId","userId":42}'; + const out = await run(text, "cnew", [2]); + expect(out.declared).toBeNull(); + expect(out.text).toBe(text); + }); + + it("leaves a top-level array alone", async () => { + const text = '["userId","x"]'; + const out = await run(text, "cnew"); + expect(out.declared).toBeNull(); + expect(out.text).toBe(text); + }); + + it("refuses an owner too long to be an id", async () => { + const text = JSON.stringify({ userId: "x".repeat(4096) }); + await expect(run(text, "cnew", [100])).rejects.toBeInstanceOf( + BackupOwnerRewriteError, + ); + }); + + it("refuses a top-level key that is not valid JSON", async () => { + await expect(run('{"bad\\q":1}', "cnew")).rejects.toBeInstanceOf( + BackupJsonError, + ); + }); +}); diff --git a/src/lib/export/backup-owner-rewrite.ts b/src/lib/export/backup-owner-rewrite.ts new file mode 100644 index 000000000..2c94d0a3a --- /dev/null +++ b/src/lib/export/backup-owner-rewrite.ts @@ -0,0 +1,184 @@ +/** + * Point a backup file at another account, on its way in. + * + * A backup names its owner once, in the top-level `userId` member. The restore + * takes the account it writes from there and from nowhere else + * (`checkOwnership` in `restore-preflight.ts`), and every domain writes its + * rows under that account (`ctx.ownerId`), never under an id read from a row. + * So the copy of an account that has since been erased goes into another + * account by changing that one member, and nothing else in the file: the copy + * stored is then that account's copy in every respect (its preview, its + * download, its restore), and the erased id is stored nowhere, not even inside + * the encrypted copy. + * + * This rewrites the member as the bytes stream past, without holding the + * document (a disaster-recovery file can be longer than any string V8 can + * hold, see `backup-json-scan.ts`). It tracks only string and nesting state, + * and parses only the top-level keys and the one value it replaces. A key is + * compared after `JSON.parse`, so an escaped spelling of `userId` is the same + * key, exactly as the reader behind it sees it; a `userId` nested anywhere + * below the top level is left alone. Every other byte passes through + * unchanged. + */ + +import { BackupJsonError } from "@/lib/export/backup-json-scan"; + +const QUOTE = 0x22; +const BACKSLASH = 0x5c; +const OPEN_BRACE = 0x7b; +const CLOSE_BRACE = 0x7d; +const OPEN_BRACKET = 0x5b; +const CLOSE_BRACKET = 0x5d; +const COMMA = 0x2c; + +/** Longer than any top-level key a backup carries. */ +const MAX_KEY_BYTES = 256; +/** Far longer than any account id; a longer owner is not an id. */ +const MAX_OWNER_BYTES = 1024; + +/** The file's owner member is not something this can rewrite. */ +export class BackupOwnerRewriteError extends Error { + constructor(message: string) { + super(message); + this.name = "BackupOwnerRewriteError"; + } +} + +type StringRole = "key" | "owner" | "other"; + +export interface BackupOwnerRewrite { + /** The bytes, with every top-level `userId` string set to the target. */ + bytes: AsyncGenerator; + /** + * The owner the file declared, once the stream has passed it: the last + * top-level `userId` string, which is the one `JSON.parse` keeps. Null when + * the file has none. + */ + declaredOwner(): string | null; +} + +export function rewriteBackupOwner( + source: AsyncIterable, + targetId: string, +): BackupOwnerRewrite { + const replacement = Buffer.from(JSON.stringify(targetId), "utf8"); + let declared: string | null = null; + + async function* bytes(): AsyncGenerator { + let depth = 0; + let topIsObject = false; + let expectKey = false; + let lastKey: string | null = null; + let inString = false; + let escaped = false; + let role: StringRole = "other"; + // The key or owner string being collected, across chunks. + let collected: Buffer[] = []; + let collectedBytes = 0; + + for await (const raw of source) { + const chunk = Buffer.isBuffer(raw) + ? raw + : Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength); + const out: Buffer[] = []; + // Where the bytes passed through unchanged start in this chunk; -1 + // while the owner value is being withheld. + let passFrom = role === "owner" && inString ? -1 : 0; + // Where the string being collected starts in this chunk. + let collectFrom = 0; + + const collect = (to: number) => { + if (to > collectFrom) { + collected.push(chunk.subarray(collectFrom, to)); + collectedBytes += to - collectFrom; + } + if (role === "key" && collectedBytes > MAX_KEY_BYTES) { + // Not a key this rewrite cares about; stop collecting it. + role = "other"; + collected = []; + collectedBytes = 0; + } + if (role === "owner" && collectedBytes > MAX_OWNER_BYTES) { + throw new BackupOwnerRewriteError( + "The file's owner is too long to be an account id", + ); + } + }; + const take = (): string => { + const text = Buffer.concat(collected, collectedBytes).toString("utf8"); + collected = []; + collectedBytes = 0; + try { + return JSON.parse(text) as string; + } catch { + throw new BackupJsonError( + "A top-level string in the file is not valid JSON", + ); + } + }; + + for (let i = 0; i < chunk.length; i++) { + const byte = chunk[i]!; + if (inString) { + if (escaped) escaped = false; + else if (byte === BACKSLASH) escaped = true; + else if (byte === QUOTE) { + inString = false; + if (role === "key") { + collect(i + 1); + lastKey = role === "key" ? take() : null; + expectKey = false; + } else if (role === "owner") { + collect(i + 1); + declared = take(); + out.push(replacement); + passFrom = i + 1; + } + role = "other"; + } + continue; + } + if (byte === QUOTE) { + inString = true; + if (depth === 1 && topIsObject && expectKey) { + role = "key"; + collectFrom = i; + } else if (depth === 1 && topIsObject && lastKey === "userId") { + role = "owner"; + collectFrom = i; + if (passFrom >= 0) out.push(chunk.subarray(passFrom, i)); + passFrom = -1; + } else { + role = "other"; + } + continue; + } + if (byte === OPEN_BRACE || byte === OPEN_BRACKET) { + depth += 1; + if (depth === 1) { + topIsObject = byte === OPEN_BRACE; + expectKey = topIsObject; + } + continue; + } + if (byte === CLOSE_BRACE || byte === CLOSE_BRACKET) { + depth -= 1; + continue; + } + if (byte === COMMA && depth === 1) { + expectKey = topIsObject; + lastKey = null; + } + } + + if (inString && (role === "key" || role === "owner")) { + collect(chunk.length); + } + if (passFrom >= 0) out.push(chunk.subarray(passFrom)); + const piece = out.length === 1 ? out[0]! : Buffer.concat(out); + if (piece.byteLength > 0) yield piece; + } + } + + return { bytes: bytes(), declaredOwner: () => declared }; +}