Files
quak/test/cli/metadata-backup.test.ts
sneak 21a1a78f07 ML data included by default, --exif is the opt-in, --all aliases --exif
ML data (face detections, CLIP embeddings) is now fetched by default
in backup-metadata. Use --no-ml to skip it. EXIF extraction (which
requires downloading every file) remains opt-in via --exif. --all is
an alias for --exif.
2026-06-09 17:38:15 -04:00

660 lines
22 KiB
TypeScript

/**
* Tests for `quak backup-metadata <dir>`.
*
* This command dumps all decrypted account metadata into a directory
* tree of plain JSON files, without downloading any file content. It
* is fast (no multi-megabyte downloads) and produces a complete
* plaintext record of every collection name, file title, creation
* date, GPS coordinate, camera model, caption, face label, and any
* other metadata the Ente clients have attached.
*
* Layout:
*
* <dir>/
* account.json { email, userID }
* collections/
* <id>-<sanitized-name>/
* _collection.json { id, name, type, magicMetadata?, ... }
* <fileID>.json { id, metadata, magicMetadata?, pubMagicMetadata? }
*
* The test builds a mock server with two collections, each with files
* that have different combinations of metadata layers, and verifies
* the output tree is correct and complete.
*/
import { gzipSync } from "node:zlib";
import {
existsSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import sodium from "libsodium-wrappers-sumo";
import { SRP, SrpServer } from "fast-srp-hap";
import { beforeAll, afterAll, describe, expect, it } from "vitest";
import {
init,
toBase64,
deriveKEK,
deriveLoginSubkey,
encryptBlob,
} from "../../src/crypto/index.js";
import sharp from "sharp";
import { Client } from "../../src/client.js";
import { runMetadataBackup } from "../../src/metadata-backup.js";
import type { KeyAttributes } from "../../src/auth/types.js";
const TEST_EMAIL = "metabackup@example.com";
const TEST_PASSWORD = "metapass";
const TEST_OPS = 2;
const TEST_MEM = 64 * 1024 * 1024;
interface MetaMockState {
verifier: Buffer;
srpAttributes: Record<string, unknown>;
keyAttributes: KeyAttributes;
encryptedToken: string;
collections: Record<string, unknown>[];
filesByCollection: Record<number, Record<string, unknown>[]>;
// For ML data and EXIF tests
encryptedMLData: Record<
number,
{ encryptedData: string; decryptionHeader: string }
>;
fileCiphertexts: Record<number, Uint8Array>;
}
let mock: MetaMockState;
let testDir: string;
const encryptSecretbox = (plaintext: Uint8Array, key: Uint8Array) => {
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key);
return { ciphertext, nonce };
};
const encryptStreamBlob = (plaintext: Uint8Array, key: Uint8Array) => {
const push = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
const ciphertext = sodium.crypto_secretstream_xchacha20poly1305_push(
push.state,
plaintext,
null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
);
return { ciphertext, header: push.header };
};
const buildMetaMock = async (): Promise<MetaMockState> => {
const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
const kek = await deriveKEK(TEST_PASSWORD, kekSalt, TEST_OPS, TEST_MEM);
const loginSubKeyBytes = deriveLoginSubkey(kek);
const srpUserID = "meta-srp";
const srpSalt = sodium.randombytes_buf(16);
const verifier = SRP.computeVerifier(
SRP.params["4096"],
Buffer.from(srpSalt),
Buffer.from(srpUserID),
Buffer.from(loginSubKeyBytes),
);
const masterKey = sodium.randombytes_buf(32);
const { ciphertext: encMK, nonce: mkNonce } = encryptSecretbox(
masterKey,
kek,
);
const kp = sodium.crypto_box_keypair();
const { ciphertext: encSK, nonce: skNonce } = encryptSecretbox(
kp.privateKey,
masterKey,
);
const tokenBytes = sodium.randombytes_buf(32);
const encToken = sodium.crypto_box_seal(tokenBytes, kp.publicKey);
const keyAttributes: KeyAttributes = {
kekSalt: toBase64(kekSalt),
encryptedKey: toBase64(encMK),
keyDecryptionNonce: toBase64(mkNonce),
publicKey: toBase64(kp.publicKey),
encryptedSecretKey: toBase64(encSK),
secretKeyDecryptionNonce: toBase64(skNonce),
memLimit: TEST_MEM,
opsLimit: TEST_OPS,
};
// Collection 1: "Vacation" with collection-level pubMagicMetadata
const ck1 = sodium.crypto_secretbox_keygen();
const { ciphertext: encCK1, nonce: ck1N } = encryptSecretbox(
ck1,
masterKey,
);
const { ciphertext: encCN1, nonce: cn1N } = encryptSecretbox(
new TextEncoder().encode("Vacation"),
ck1,
);
const collPubMagic = JSON.stringify({ coverID: 999, sortBy: "date" });
const { ciphertext: encCollPM, header: collPMHeader } = encryptStreamBlob(
new TextEncoder().encode(collPubMagic),
ck1,
);
const rawColl1 = {
id: 10,
owner: { id: 42 },
encryptedKey: toBase64(encCK1),
keyDecryptionNonce: toBase64(ck1N),
encryptedName: toBase64(encCN1),
nameDecryptionNonce: toBase64(cn1N),
type: "album",
updationTime: 1700000000000000,
pubMagicMetadata: {
version: 1,
count: 1,
data: toBase64(encCollPM),
header: toBase64(collPMHeader),
},
};
// Collection 2: "Work" with no magic metadata
const ck2 = sodium.crypto_secretbox_keygen();
const { ciphertext: encCK2, nonce: ck2N } = encryptSecretbox(
ck2,
masterKey,
);
const { ciphertext: encCN2, nonce: cn2N } = encryptSecretbox(
new TextEncoder().encode("Work"),
ck2,
);
const rawColl2 = {
id: 20,
owner: { id: 42 },
encryptedKey: toBase64(encCK2),
keyDecryptionNonce: toBase64(ck2N),
encryptedName: toBase64(encCN2),
nameDecryptionNonce: toBase64(cn2N),
type: "folder",
updationTime: 1700000000000000,
};
// File 100 in coll 10: has metadata + pubMagicMetadata
const fk1 = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { ciphertext: encFK1, nonce: fk1N } = encryptSecretbox(fk1, ck1);
const meta1 = JSON.stringify({
title: "beach.jpg",
fileType: 0,
creationTime: 1700000000000000,
modificationTime: 1700000000000000,
latitude: 35.6762,
longitude: 139.6503,
});
const { ciphertext: encMeta1, header: meta1Header } = encryptStreamBlob(
new TextEncoder().encode(meta1),
fk1,
);
const pubMagic1 = JSON.stringify({
w: 3000,
h: 2000,
cameraMake: "SONY",
cameraModel: "DSC-RX1RM3",
});
const { ciphertext: encPM1, header: pm1Header } = encryptStreamBlob(
new TextEncoder().encode(pubMagic1),
fk1,
);
const rawFile1 = {
id: 100,
collectionID: 10,
ownerID: 42,
encryptedKey: toBase64(encFK1),
keyDecryptionNonce: toBase64(fk1N),
metadata: {
encryptedData: toBase64(encMeta1),
decryptionHeader: toBase64(meta1Header),
},
pubMagicMetadata: {
version: 1,
count: 1,
data: toBase64(encPM1),
header: toBase64(pm1Header),
},
file: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
thumbnail: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
updationTime: 1700000000000000,
};
// File 200 in coll 20: metadata only, no magic metadata
const fk2 = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { ciphertext: encFK2, nonce: fk2N } = encryptSecretbox(fk2, ck2);
const meta2 = JSON.stringify({
title: "diagram.png",
fileType: 0,
creationTime: 1710000000000000,
modificationTime: 1710000000000000,
});
const { ciphertext: encMeta2, header: meta2Header } = encryptStreamBlob(
new TextEncoder().encode(meta2),
fk2,
);
const rawFile2 = {
id: 200,
collectionID: 20,
ownerID: 42,
encryptedKey: toBase64(encFK2),
keyDecryptionNonce: toBase64(fk2N),
metadata: {
encryptedData: toBase64(encMeta2),
decryptionHeader: toBase64(meta2Header),
},
file: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
thumbnail: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
updationTime: 1710000000000000,
};
// Encrypt ML data for file 100 (gzipped JSON, encrypted with file key)
const mlPayload = JSON.stringify({
face: {
version: 1,
client: "test",
width: 3000,
height: 2000,
faces: [
{
faceID: "face-abc",
detection: {
box: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 },
landmarks: [
{ x: 0.15, y: 0.25 },
{ x: 0.25, y: 0.25 },
],
},
score: 0.98,
blur: 12.5,
embedding: [0.1, 0.2, 0.3],
},
],
},
clip: {
version: 1,
client: "test",
embedding: [0.5, 0.6, 0.7],
},
});
const gzipped = gzipSync(Buffer.from(mlPayload));
const { header: mlHeader, ciphertext: mlCiphertext } = encryptBlob(
new Uint8Array(gzipped),
fk1,
);
// Generate a real JPEG for EXIF extraction tests
const tinyJpeg = await sharp({
create: { width: 100, height: 80, channels: 3, background: "red" },
})
.jpeg({ quality: 80 })
.toBuffer();
const filePush1 =
sodium.crypto_secretstream_xchacha20poly1305_init_push(fk1);
const encFileBody1 = sodium.crypto_secretstream_xchacha20poly1305_push(
filePush1.state,
new Uint8Array(tinyJpeg),
null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
);
// Patch rawFile1's file.decryptionHeader to match the push header
rawFile1.file.decryptionHeader = toBase64(filePush1.header);
return {
verifier,
srpAttributes: {
srpUserID,
srpSalt: toBase64(srpSalt),
memLimit: TEST_MEM,
opsLimit: TEST_OPS,
kekSalt: toBase64(kekSalt),
isEmailMFAEnabled: false,
},
keyAttributes,
encryptedToken: toBase64(encToken),
collections: [rawColl1, rawColl2],
filesByCollection: { 10: [rawFile1], 20: [rawFile2] },
encryptedMLData: {
100: {
encryptedData: toBase64(mlCiphertext),
decryptionHeader: toBase64(mlHeader),
},
},
fileCiphertexts: { 100: encFileBody1 },
};
};
const buildMetaFetch = (m: MetaMockState) => {
let srpServer: SrpServer;
return (async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const path = new URL(url).pathname;
const json = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
if (path === "/users/srp/attributes")
return json({ attributes: m.srpAttributes });
if (path === "/users/srp/create-session") {
const body = JSON.parse(init?.body as string);
const serverKey = await SRP.genKey();
srpServer = new SrpServer(
SRP.params["4096"],
m.verifier,
serverKey,
);
const B = srpServer.computeB();
srpServer.setA(Buffer.from(body.srpA, "base64"));
return json({ sessionID: "s1", srpB: B.toString("base64") });
}
if (path === "/users/srp/verify-session") {
const body = JSON.parse(init?.body as string);
srpServer.checkM1(Buffer.from(body.srpM1, "base64"));
return json({
srpM2: srpServer.computeM2().toString("base64"),
id: 42,
keyAttributes: m.keyAttributes,
encryptedToken: m.encryptedToken,
});
}
if (path === "/collections/v2")
return json({ collections: m.collections });
if (path === "/collections/v2/diff") {
const collID = Number(
new URL(url).searchParams.get("collectionID"),
);
return json({
diff: m.filesByCollection[collID] ?? [],
hasMore: false,
});
}
if (path === "/files/data/fetch") {
const body = JSON.parse(init?.body as string);
const data = (body.fileIDs as number[])
.filter((id: number) => m.encryptedMLData[id])
.map((id: number) => ({
fileID: id,
...m.encryptedMLData[id],
updatedAt: 1700000000000000,
}));
return json({ data });
}
if (
url.includes("files.ente.io") ||
path.startsWith("/files/download/")
) {
const parsed = new URL(url);
const fileID = Number(
parsed.searchParams.get("fileID") ?? path.split("/").pop(),
);
const ct = m.fileCiphertexts[fileID];
if (ct) return new Response(ct, { status: 200 });
return new Response("not found", { status: 404 });
}
return new Response("not found", { status: 404 });
}) as typeof globalThis.fetch;
};
beforeAll(async () => {
await init();
await sodium.ready;
mock = await buildMetaMock();
testDir = mkdtempSync(join(tmpdir(), "quak-meta-backup-test-"));
});
afterAll(() => {
if (testDir && existsSync(testDir))
rmSync(testDir, { recursive: true, force: true });
});
describe("quak backup-metadata", () => {
it("writes account.json with email and userID", async () => {
const outDir = join(testDir, "full");
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMetaFetch(mock) },
});
await runMetadataBackup(client, outDir);
const account = JSON.parse(
readFileSync(join(outDir, "account.json"), "utf-8"),
);
expect(account.email).toBe(TEST_EMAIL);
expect(account.userID).toBe(42);
});
it("creates per-collection directories with _collection.json", async () => {
const outDir = join(testDir, "collections");
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMetaFetch(mock) },
});
await runMetadataBackup(client, outDir);
const collDirs = readdirSync(join(outDir, "collections"));
expect(collDirs.length).toBe(2);
// Find the Vacation collection dir (prefixed with ID)
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
expect(vacDir).toBeDefined();
const collMeta = JSON.parse(
readFileSync(
join(outDir, "collections", vacDir, "_collection.json"),
"utf-8",
),
);
expect(collMeta.id).toBe(10);
expect(collMeta.name).toBe("Vacation");
expect(collMeta.type).toBe("album");
});
it("decrypts collection-level pubMagicMetadata", async () => {
const outDir = join(testDir, "coll-magic");
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMetaFetch(mock) },
});
await runMetadataBackup(client, outDir);
const collDirs = readdirSync(join(outDir, "collections"));
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
const collMeta = JSON.parse(
readFileSync(
join(outDir, "collections", vacDir, "_collection.json"),
"utf-8",
),
);
expect(collMeta.pubMagicMetadata).toBeDefined();
expect(collMeta.pubMagicMetadata.coverID).toBe(999);
expect(collMeta.pubMagicMetadata.sortBy).toBe("date");
});
it("writes per-file JSON with all three metadata layers", async () => {
const outDir = join(testDir, "file-meta");
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMetaFetch(mock) },
});
await runMetadataBackup(client, outDir);
const collDirs = readdirSync(join(outDir, "collections"));
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
const fileMeta = JSON.parse(
readFileSync(
join(outDir, "collections", vacDir, "100.json"),
"utf-8",
),
);
expect(fileMeta.id).toBe(100);
expect(fileMeta.metadata.title).toBe("beach.jpg");
expect(fileMeta.metadata.latitude).toBeCloseTo(35.6762);
expect(fileMeta.pubMagicMetadata.cameraMake).toBe("SONY");
expect(fileMeta.pubMagicMetadata.w).toBe(3000);
});
it("handles files with no magic metadata gracefully", async () => {
const outDir = join(testDir, "no-magic");
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMetaFetch(mock) },
});
await runMetadataBackup(client, outDir);
const collDirs = readdirSync(join(outDir, "collections"));
const workDir = collDirs.find((d) => d.includes("Work"))!;
const fileMeta = JSON.parse(
readFileSync(
join(outDir, "collections", workDir, "200.json"),
"utf-8",
),
);
expect(fileMeta.id).toBe(200);
expect(fileMeta.metadata.title).toBe("diagram.png");
expect(fileMeta.pubMagicMetadata).toBeUndefined();
expect(fileMeta.magicMetadata).toBeUndefined();
});
it("is incremental: second run does not fail", async () => {
const outDir = join(testDir, "incremental");
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMetaFetch(mock) },
});
await runMetadataBackup(client, outDir);
await runMetadataBackup(client, outDir);
const account = JSON.parse(
readFileSync(join(outDir, "account.json"), "utf-8"),
);
expect(account.email).toBe(TEST_EMAIL);
});
it("fetches and decrypts ML data when --ml is set", async () => {
const outDir = join(testDir, "ml-data");
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMetaFetch(mock) },
});
await runMetadataBackup(client, outDir, { mlData: true });
const collDirs = readdirSync(join(outDir, "collections"));
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
const fileMeta = JSON.parse(
readFileSync(
join(outDir, "collections", vacDir, "100.json"),
"utf-8",
),
);
// ML data should be present and decrypted
expect(fileMeta.mlData).toBeDefined();
expect(fileMeta.mlData.face).toBeDefined();
expect(fileMeta.mlData.face.faces.length).toBe(1);
expect(fileMeta.mlData.face.faces[0].faceID).toBe("face-abc");
expect(fileMeta.mlData.face.faces[0].score).toBeCloseTo(0.98);
expect(fileMeta.mlData.face.faces[0].detection.box.x).toBeCloseTo(0.1);
expect(fileMeta.mlData.clip).toBeDefined();
expect(fileMeta.mlData.clip.embedding).toEqual([0.5, 0.6, 0.7]);
});
it("includes ML data by default", async () => {
const outDir = join(testDir, "ml-default");
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMetaFetch(mock) },
});
await runMetadataBackup(client, outDir);
const collDirs = readdirSync(join(outDir, "collections"));
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
const fileMeta = JSON.parse(
readFileSync(
join(outDir, "collections", vacDir, "100.json"),
"utf-8",
),
);
expect(fileMeta.mlData).toBeDefined();
expect(fileMeta.mlData.face.faces[0].faceID).toBe("face-abc");
});
it("excludes ML data when mlData: false", async () => {
const outDir = join(testDir, "no-ml");
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMetaFetch(mock) },
});
await runMetadataBackup(client, outDir, { mlData: false });
const collDirs = readdirSync(join(outDir, "collections"));
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
const fileMeta = JSON.parse(
readFileSync(
join(outDir, "collections", vacDir, "100.json"),
"utf-8",
),
);
expect(fileMeta.mlData).toBeUndefined();
});
it("extracts EXIF from downloaded files when --exif is set", async () => {
const outDir = join(testDir, "exif-data");
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMetaFetch(mock) },
});
await runMetadataBackup(client, outDir, { exif: true });
const collDirs = readdirSync(join(outDir, "collections"));
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
const fileMeta = JSON.parse(
readFileSync(
join(outDir, "collections", vacDir, "100.json"),
"utf-8",
),
);
// imageMetadata from sharp should be present
expect(fileMeta.imageMetadata).toBeDefined();
expect(fileMeta.imageMetadata.format).toBe("jpeg");
expect(fileMeta.imageMetadata.width).toBe(100);
expect(fileMeta.imageMetadata.height).toBe(80);
expect(fileMeta.imageMetadata.channels).toBe(3);
});
});