ML data that does not decrypt is recorded with its reason (closes #198)
check / check (push) Waiting to run
check / check (push) Waiting to run
`fetchMLDataBatch` dropped an entry that did not decrypt, gunzip or parse, so the file looked like one with no ML data and the run exited 0. It now returns the reason beside the payloads, also for ML data that is not a JSON object. The library gives the reasons from its last ML data fetch that succeeded in `status().mlDataErrors` and the `fetchMLData` "done" event; a fetch that fails leaves them as they were. `quak backup` writes the reason as `mlDataError` for a file with no ML data cached and records the file in `failures.json`. `backup-metadata` writes `mlDataError` for it and exits 1; `runMetadataBackup` returns `filesWithMLDataError`. Judgement call: a file whose ML data is cached keeps it in its JSON when a newer copy does not decode, as when the fetch fails. Model: opus-5-5
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
* data this file encrypts with libsodium, as Ente's clients encrypt it. So
|
||||
* quak's own code fetches each original, decrypts it, checks it against the
|
||||
* hash its uploader recorded and unpacks a live photo, and quak's own retries
|
||||
* repeat a failed request. Two tests run `lib.backup()` instead, as a program
|
||||
* repeat a failed request. Three tests run `lib.backup()` instead, as a program
|
||||
* using quak as a library does.
|
||||
*
|
||||
* The account, user 42, has three albums:
|
||||
@@ -877,6 +877,133 @@ describe("quak backup", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// The server sends beach.jpg's ML data encrypted under a key that is not
|
||||
// the file's, so quak cannot decrypt it. The file's JSON has the reason
|
||||
// in place of the ML data, and the file goes into `failures.json` with
|
||||
// it, so the exit code is 1. The other two files have no ML data, which
|
||||
// is no failure.
|
||||
it("records a file whose ML data does not decrypt in its JSON and in failures.json, and exits 1", async () => {
|
||||
const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||
const mlData = encryptStreamBlob(
|
||||
gzipSync(JSON.stringify(ML_DATA)),
|
||||
otherKey,
|
||||
);
|
||||
const fetch: typeof globalThis.fetch = async (input, init) => {
|
||||
if (new URL(String(input)).pathname !== "/files/data/fetch") {
|
||||
return server(input, init);
|
||||
}
|
||||
const data = [
|
||||
{
|
||||
fileID: BEACH.id,
|
||||
encryptedData: toBase64(mlData.ciphertext),
|
||||
decryptionHeader: toBase64(mlData.header),
|
||||
},
|
||||
];
|
||||
return new Response(JSON.stringify({ data }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
const dir = mkdtempSync(join(root, "backup-"));
|
||||
|
||||
const { code, stderr } = await backup(dir, {}, fetch);
|
||||
|
||||
expect(code).toBe(1);
|
||||
const reason = "secretstream chunk authentication failed";
|
||||
const beachJSON = readJSON(join(dir, DAY, "2024-03-01.100.json"));
|
||||
expect(beachJSON).not.toHaveProperty("mlData");
|
||||
expect(beachJSON.mlDataError).toBe(reason);
|
||||
const failures = readJSON(join(dir, "failures.json"));
|
||||
expect(Object.keys(failures.files)).toEqual(["100"]);
|
||||
expect(failures.files["100"]).toMatchObject({
|
||||
fileID: 100,
|
||||
title: "beach.jpg",
|
||||
error: `ML data: ${reason}`,
|
||||
});
|
||||
expect(stderr).toContain(`beach.jpg (id 100): ML data: ${reason}\n`);
|
||||
for (const fileID of [200, 300]) {
|
||||
const json = readJSON(join(dir, DAY, `2024-03-01.${fileID}.json`));
|
||||
expect(json).not.toHaveProperty("mlData");
|
||||
expect(json).not.toHaveProperty("mlDataError");
|
||||
}
|
||||
});
|
||||
|
||||
// A program using quak as a library backs the account up, and another ML
|
||||
// data fetch, as one the library's refresh starts, fails while the backup
|
||||
// writes the JSON files. The backup's own fetch got beach.jpg's ML data
|
||||
// under a key that is not the file's; the fetch that fails leaves that
|
||||
// reason in place, so beach.jpg's JSON and `failures.json` still give it.
|
||||
it("keeps the reason in beach.jpg's JSON and in failures.json when an ML data fetch fails while lib.backup() writes the JSON files", async () => {
|
||||
const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||
const mlData = encryptStreamBlob(
|
||||
gzipSync(JSON.stringify(ML_DATA)),
|
||||
otherKey,
|
||||
);
|
||||
let mlDataFetchFails = false;
|
||||
const fetch: typeof globalThis.fetch = async (input, init) => {
|
||||
if (new URL(String(input)).pathname !== "/files/data/fetch") {
|
||||
return server(input, init);
|
||||
}
|
||||
if (mlDataFetchFails) throw new Error("the connection was reset");
|
||||
const data = [
|
||||
{
|
||||
fileID: BEACH.id,
|
||||
encryptedData: toBase64(mlData.ciphertext),
|
||||
decryptionHeader: toBase64(mlData.header),
|
||||
},
|
||||
];
|
||||
return new Response(JSON.stringify({ data }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
const dir = mkdtempSync(join(root, "backup-"));
|
||||
const lib = await Library.open({
|
||||
client: loadSession(join(sessionDir, "session.json"), {
|
||||
fetch,
|
||||
retry: { ...UNATTENDED_RETRY_OPTIONS, sleep: async () => {} },
|
||||
})!,
|
||||
cacheDirectory: `${dir}-cache`,
|
||||
downloadDirectory: dir,
|
||||
refreshIntervalSeconds: Infinity,
|
||||
precacheThumbnails: false,
|
||||
precacheOriginals: false,
|
||||
});
|
||||
try {
|
||||
// The backup reads a file's cached ML data just before it writes
|
||||
// that file's JSON. The first time, a refresh starts an ML data
|
||||
// fetch whose request fails, and the backup goes on once that
|
||||
// fetch has failed.
|
||||
const forFile = lib.mldata.forFile.bind(lib.mldata);
|
||||
vi.spyOn(lib.mldata, "forFile").mockImplementationOnce(
|
||||
async (args) => {
|
||||
mlDataFetchFails = true;
|
||||
await lib.fresh();
|
||||
await vi.waitFor(
|
||||
() => expect(lib.status().lastMLError).toBeDefined(),
|
||||
{ timeout: 2000, interval: 5 },
|
||||
);
|
||||
return forFile(args);
|
||||
},
|
||||
);
|
||||
|
||||
const result = await lib.backup();
|
||||
|
||||
expect(result.failed).toBe(1);
|
||||
const reason = "secretstream chunk authentication failed";
|
||||
const beachJSON = readJSON(join(dir, DAY, "2024-03-01.100.json"));
|
||||
expect(beachJSON).not.toHaveProperty("mlData");
|
||||
expect(beachJSON.mlDataError).toBe(reason);
|
||||
const failures = readJSON(join(dir, "failures.json"));
|
||||
expect(Object.keys(failures.files)).toEqual(["100"]);
|
||||
expect(failures.files["100"]).toMatchObject({
|
||||
fileID: 100,
|
||||
title: "beach.jpg",
|
||||
error: `ML data: ${reason}`,
|
||||
});
|
||||
} finally {
|
||||
await lib.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Something changed beach.jpg on disk after it was backed up. A run with
|
||||
// --verify hashes every stored original, finds that one no longer matches
|
||||
// the hash Ente recorded, says so, and downloads it again; it put back the
|
||||
|
||||
Reference in New Issue
Block a user