Compare commits

1 Commits
Author SHA1 Message Date
sneak 375d8ae28a ML data that does not decrypt is recorded with its reason (closes #198)
check / check (push) Canceled after 0s
`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. The library keeps the reasons from
its last ML data fetch in memory only and gives them in
`status().mlDataErrors` and the `fetchMLData` "done" event. `quak backup`
writes the reason as `mlDataError` for a file with no ML data cached and
records the file in `failures.json` as `ML data: REASON`. `backup-metadata`
writes `mlDataError` for it and exits 1; `runMetadataBackup` now returns how
many files have `mlDataError`.

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
2026-10-16 03:42:19 +00:00
13 changed files with 157 additions and 559 deletions
+34 -48
View File
@@ -159,11 +159,10 @@ After a `1`, the next run fetches each missing original again, fetches the ML
data that is not cached, and rebuilds the album links. An album quak could not data that is not cached, and rebuilds the album links. An album quak could not
decrypt, or a file in `failures.json` because quak could not decrypt it, keeps decrypt, or a file in `failures.json` because quak could not decrypt it, keeps
the exit code at `1` until the server sends a copy that decrypts, or deletes it the exit code at `1` until the server sends a copy that decrypts, or deletes it
(see "Backup layout"). An original that a `--verify` run could not read, or (see "Backup layout"). An original that a `--verify` run could not read, or put
could not replace with a download that matches, stays at its save path as it back with bytes that still do not match, stays at its save path. Only a later
was. Only a later `--verify` run checks it again: a run without `--verify` `--verify` run checks it again: a run without `--verify` leaves it as it is and
leaves it as it is and takes the file out of `failures.json`, so that run can takes the file out of `failures.json`, so that run can exit 0.
exit 0.
## Examples ## Examples
@@ -693,8 +692,8 @@ reads no tag from is recorded, base64, as `exifRaw`, with the reason in
and `helper fix-missing-thumbnails` take `--json` for machine-readable output. and `helper fix-missing-thumbnails` take `--json` for machine-readable output.
`backup --verify` also hashes the originals already in the backup and replaces `backup --verify` also hashes the originals already in the backup and replaces
any that do not match the content hash Ente records; one it cannot replace stays any that do not match the content hash Ente records; one it cannot replace goes
as it was and goes into `failures.json` (see "Backup layout"). into `failures.json` (see "Backup layout").
`backup-metadata` fetches ML data in requests of up to 200 files. When a request `backup-metadata` fetches ML data in requests of up to 200 files. When a request
fails, the error is logged, each of its files is written with the reason in an fails, the error is logged, each of its files is written with the reason in an
@@ -770,11 +769,8 @@ and the next run fetches it again. The same goes for a file with no ML data in
the cache whose ML data the server sent but quak could not decrypt, gunzip or the cache whose ML data the server sent but quak could not decrypt, gunzip or
parse. Its `mlDataError` says why, and `failures.json` records it with an error parse. Its `mlDataError` says why, and `failures.json` records it with an error
starting `ML data: `. quak keeps that reason in memory only and caches nothing starting `ML data: `. quak keeps that reason in memory only and caches nothing
for the file, so the next run asks for its ML data again. An ML data fetch that for the file, so the next run asks for its ML data again. The JSON files are
fails, such as one the library's refresh starts while the backup writes the JSON rewritten on every run, so ML data that arrived since the last run appears.
files, leaves the reasons as the last fetch that succeeded left them. The JSON
files are rewritten on every run, so ML data that arrived since the last run
appears.
A file's JSON holds, as `imageMetadata`, what `backup-metadata --exif` records A file's JSON holds, as `imageMetadata`, what `backup-metadata --exif` records
from its original, or from a live photo's image: `format`, `width` and `height` from its original, or from a live photo's image: `format`, `width` and `height`
@@ -888,41 +884,33 @@ original already at its save path the way a download is checked (see "On-disk
cache layout" below): its bytes, read in chunks, or a live photo's image and cache layout" below): its bytes, read in chunks, or a live photo's image and
video, joined as `<imageHash>:<videoHash>`. An original that matches the content video, joined as `<imageHash>:<videoHash>`. An original that matches the content
hash its metadata records is left as it is. One that does not is logged on one hash its metadata records is left as it is. One that does not is logged on one
line naming the file, and replaced once a matching copy is downloaded, in the line naming the file, deleted (a live photo's image and video both), and put
same run. The copy is downloaded even when the cache holds the original, because back in the same run like a missing one: downloaded, or copied from the cache if
a copy from the cache is not checked as a download is. It is written to a the cache holds it. What is put back is hashed too, because a copy from the
temporary file beside the original, a live photo's image and video each to its cache is not checked as a download is. If it still does not match, it stays at
own, hashed the same way, and renamed over the original only when it matches; a its save path and the file goes into `failures.json`, as it does when the
live photo's image and video are both hashed before either is renamed. Until download fails. A file whose metadata records no hash is left as it is and
then the original stays as it was: when the download fails, or what it brings counted as unchecked. A stored original that cannot be read is left as it is and
does not match either, the temporary files are deleted, the original keeps its counts as failed. The summary and `--json` add the counts `verified`,
bytes and its album links, and the file goes into `failures.json`. A run that `mismatched` and `unchecked`, all of originals that were already stored; one
stops between renaming a live photo's new image and its new video leaves the new first downloaded in this run is in none of them. A mismatch that was put back
image beside the old video, which a later `--verify` run replaces if the video with matching bytes does not make the exit code non-zero. Without `--verify`
was the part that did not match. A file whose metadata records no hash is left nothing is hashed, the summary is unchanged, and the three counts are 0 in
as it is and counted as unchecked. A stored original that cannot be read is left `--json`.
as it is and counts as failed. The summary and `--json` add the counts
`verified`, `mismatched` and `unchecked`, all of originals that were already
stored; one first downloaded in this run is in none of them. A mismatch that was
replaced with matching bytes does not make the exit code non-zero. Without
`--verify` nothing is hashed, the summary is unchanged, and the three counts are
0 in `--json`.
Each original is written to a temporary file in the same directory, synced to Each original is written to a temporary file in the same directory, synced to
disk, and renamed into place, so an original is either complete or absent, even disk, and renamed into place, so an original is either complete or absent, even
after a power cut. A downloaded original's temporary file is named after a power cut. A downloaded original's temporary file is named
`.quak-<pid>-<random>.tmp`, one copied from the cache `.quak-<pid>-<random>.tmp`, one copied from the cache
`.quak-backup-YYYY-MM-DD.<fileID>.<ext>-<pid>-<random>.tmp`. A copy that `.quak-backup-YYYY-MM-DD.<fileID>.<ext>-<pid>-<random>.tmp`. A run that is
`--verify` downloads to replace an original is written to such a killed can leave one of these temporary files behind; the next backup deletes
`.quak-<pid>-<random>.tmp` too, a live photo's image and video to those whose process is no longer running. The content cache uses the same
`.quak-<pid>-<random>.<ext>`, each with its own extension. A run that is killed scheme, and opening a library deletes the temporary files in the cache whose
can leave one of these temporary files behind; the next backup deletes those process is no longer running, so a download another process has in progress in
whose process is no longer running. The content cache uses the same scheme, and the same cache is left alone. The rename replaces whatever was at the
opening a library deletes the temporary files in the cache whose process is no destination rather than writing through it: a symlink there is replaced, not
longer running, so a download another process has in progress in the same cache followed, and the new file has the temporary file's permissions, not those of
is left alone. The rename replaces whatever was at the destination rather than the file it replaced.
writing through it: a symlink there is replaced, not followed, and the new file
has the temporary file's permissions, not those of the file it replaced.
## TODO ## TODO
@@ -997,12 +985,10 @@ the precache fetches already running have all finished, so the cache directory
can then be removed. can then be removed.
`lib.status().mlDataErrors` lists, as `{ fileID, error }`, each file whose ML `lib.status().mlDataErrors` lists, as `{ fileID, error }`, each file whose ML
data the last ML data fetch that succeeded got but could not decrypt, gunzip or data the last ML data fetch got but could not decrypt, gunzip or parse; the
parse; the `fetchMLData` event with status `"done"` carries the same list. A `fetchMLData` event with status `"done"` carries the same list. Such ML data is
fetch that is still running leaves the list as it is until it ends, and a fetch not cached, so the next ML data fetch asks for it again. The reasons are kept in
that fails leaves it as it was. Such ML data is not cached, so the next ML data memory only, never in the cache.
fetch asks for it again. The reasons are kept in memory only, never in the
cache.
### Default reads vs. fresh reads ### Default reads vs. fresh reads
+11 -23
View File
@@ -17,9 +17,10 @@ pre-1.0
# Next Step # Next Step
One bug is open: https://git.eeqj.de/sneak/quak/issues/205. The check of quak Two bugs are open: https://git.eeqj.de/sneak/quak/issues/199 and
against a live Ente account, https://git.eeqj.de/sneak/quak/issues/187, needs an https://git.eeqj.de/sneak/quak/issues/205. The check of quak against a live Ente
account only sneak has, so it waits on him. account, https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak
has, so it waits on him.
Tagging and releases are decided by sneak alone, and happen only when he Tagging and releases are decided by sneak alone, and happen only when he
declares one. declares one.
@@ -28,26 +29,13 @@ declares one.
- 2026-10-16: ML data the server sends that does not decrypt, gunzip or parse is - 2026-10-16: ML data the server sends that does not decrypt, gunzip or parse is
no longer taken for no ML data (issue 198). `fetchMLDataBatch` returns why for no longer taken for no ML data (issue 198). `fetchMLDataBatch` returns why for
each such file beside the payloads; ML data that parses to something other each such file beside the payloads. The library keeps those reasons from its
than a JSON object, such as `null`, is one of them. The library keeps the last ML data fetch in memory, not in the cache, and gives them in
reasons from its last ML data fetch that succeeded in memory, not in the `status().mlDataErrors` and in the `fetchMLData` event with status `"done"`.
cache, and gives them in `status().mlDataErrors` and in the `fetchMLData` `quak backup` writes the reason as `mlDataError` in the file's JSON when no ML
event with status `"done"`; a fetch still running or one that fails does not data is cached for it, and records the file in `failures.json` with an error
change them. `quak backup` writes the reason as `mlDataError` in the file's starting `ML data: `; `backup-metadata` writes `mlDataError` too. Both exit 1.
JSON when no ML data is cached for it, and records the file in `failures.json` Tests serve ML data encrypted under a key that is not the file's.
with an error starting `ML data: `; `backup-metadata` writes `mlDataError`
too. Both exit 1. Tests serve ML data encrypted under a key that is not the
file's.
- 2026-10-16: With `--verify`, an original whose hash does not match is no
longer removed before its replacement is downloaded (issue 199).
`replaceOriginal` in `src/library/content.ts` downloads the replacement to a
temporary file beside it, a live photo's image and video each to its own,
hashes it and renames it over the original only when it matches. When the
download fails or does not match either, the original keeps its bytes and
links, and the file goes into `failures.json`. The replacement is always
downloaded, through the new `fetchOriginal` of the content cache, never copied
from the cache.
- 2026-10-16: The `~/bin/quak` that `make install` builds retries a refused - 2026-10-16: The `~/bin/quak` that `make install` builds retries a refused
connection and a name that does not resolve, and the backup records either as connection and a name that does not resolve, and the backup records either as
+59 -39
View File
@@ -41,9 +41,8 @@
// //
// With `verify`, each original already at its save path is hashed as the // With `verify`, each original already at its save path is hashed as the
// download check hashes it, and one that does not match the content hash its // download check hashes it, and one that does not match the content hash its
// metadata records is replaced once a matching copy is downloaded, in the same // metadata records is removed and fetched again in the same run. What is put
// run. Until then it stays as it is: a download that fails, or that does not // back is hashed too, and recorded as failed if it still does not match.
// match either, leaves it in place and is recorded as failed.
// //
// Resilience (issue #8): no per-file condition aborts the run. A failed // Resilience (issue #8): no per-file condition aborts the run. A failed
// download, a failed symlink, or ML data missing because the ML data fetch // download, a failed symlink, or ML data missing because the ML data fetch
@@ -62,6 +61,7 @@
// code. // code.
import { import {
createReadStream,
lstatSync, lstatSync,
mkdirSync, mkdirSync,
readdirSync, readdirSync,
@@ -77,6 +77,12 @@ import { readFile } from "node:fs/promises";
import { dirname, extname, join, relative, resolve } from "node:path"; import { dirname, extname, join, relative, resolve } from "node:path";
import lockfile from "proper-lockfile"; import lockfile from "proper-lockfile";
import {
chunkHashFinal,
chunkHashInit,
chunkHashUpdate,
init,
} from "./crypto/index.js";
import { removeLeftoverTempFiles } from "./download/index.js"; import { removeLeftoverTempFiles } from "./download/index.js";
import { describeError } from "./errors.js"; import { describeError } from "./errors.js";
import { import {
@@ -88,10 +94,8 @@ import {
import { import {
copyAtomic, copyAtomic,
placeOriginal, placeOriginal,
replaceOriginal,
savePath, savePath,
storedAtSavePath, storedAtSavePath,
storedHash,
} from "./library/content.js"; } from "./library/content.js";
import { representative } from "./library/records.js"; import { representative } from "./library/records.js";
import type { Undecryptable } from "./library/store.js"; import type { Undecryptable } from "./library/store.js";
@@ -148,9 +152,9 @@ export interface BackupResult {
// Originals already at their save path and left untouched. // Originals already at their save path and left untouched.
skipped: number; skipped: number;
// With `verify`, the originals already at their save path whose hash // With `verify`, the originals already at their save path whose hash
// matched, those whose hash did not (each replaced once a matching copy is // matched, those whose hash did not (each removed and fetched again), and
// downloaded), and those whose metadata records no hash (left as they // those whose metadata records no hash (left as they are). All three are
// are). All three are zero without `verify`. // zero without `verify`.
verified: number; verified: number;
mismatched: number; mismatched: number;
unchecked: number; unchecked: number;
@@ -188,15 +192,6 @@ export interface BackupLibrary {
fileID: number, fileID: number,
destination: string, destination: string,
): Promise<{ path: string; videoPath?: string }>; ): Promise<{ path: string; videoPath?: string }>;
// Fetch an original's bytes to `destination` through the content pool,
// a live photo as its image and its video beside it, even when the cache
// or the library's save path already holds it, and return where they
// landed. With `verify`, an original whose hash does not match is replaced
// with what this fetches.
fetchOriginal(
fileID: number,
destination: string,
): Promise<{ path: string; videoPath?: string }>;
thumbnail(fileID: number): Promise<{ path: string }>; thumbnail(fileID: number): Promise<{ path: string }>;
// How many originals the backup works on at once: the limit of the // How many originals the backup works on at once: the limit of the
// library's content pool. // library's content pool.
@@ -208,10 +203,8 @@ export interface BackupLibrary {
// A file's cached ML data, as `lib.mldata.forFile()` returns it, or // A file's cached ML data, as `lib.mldata.forFile()` returns it, or
// undefined when none is cached. // undefined when none is cached.
mlData(fileID: number): Promise<MLData | undefined>; mlData(fileID: number): Promise<MLData | undefined>;
// Why the ML data the last ML data fetch that succeeded got for a file did // Why the ML data the last ML data fetch got for a file did not decrypt,
// not decrypt, gunzip or parse, or undefined when it did or the fetch got // gunzip or parse, or undefined when it did or the fetch got none.
// none. A fetch still running does not change it. A fetch that fails
// leaves it as it was.
mlDataError(fileID: number): unknown; mlDataError(fileID: number): unknown;
} }
@@ -454,6 +447,26 @@ const saveLedger = (path: string, ledger: Map<number, FailureEntry>): void => {
); );
}; };
// The content hash of the original stored at `stored`, computed as the download
// check computes it: over each file's bytes, read in chunks, and for a live
// photo `<imageHash>:<videoHash>`.
const storedHash = async (stored: {
path: string;
videoPath?: string;
}): Promise<string> => {
await init();
const hashFile = async (path: string): Promise<string> => {
const state = chunkHashInit();
for await (const chunk of createReadStream(path)) {
chunkHashUpdate(state, chunk as Buffer);
}
return chunkHashFinal(state);
};
const hash = await hashFile(stored.path);
if (stored.videoPath === undefined) return hash;
return `${hash}:${await hashFile(stored.videoPath)}`;
};
// A file's EXIF, XMP and dimensions as its JSON holds them: what // A file's EXIF, XMP and dimensions as its JSON holds them: what
// `extractImageMetadata` found in its original, or why the original could not // `extractImageMetadata` found in its original, or why the original could not
// be read. // be read.
@@ -675,13 +688,11 @@ const runLockedBackup = async (
// through the content cache/pools, as `Photo.download()` does, and fetch // through the content cache/pools, as `Photo.download()` does, and fetch
// the optional thumbnails; a present file is left as is. With `verify`, a // the optional thumbnails; a present file is left as is. With `verify`, a
// present original is hashed first, and one that does not match the hash // present original is hashed first, and one that does not match the hash
// its metadata records is downloaded again and replaced by // its metadata records is removed and fetched like a missing one. One
// `replaceOriginal`, which leaves it as it was when that download fails // that cannot be read is recorded as failed and left where it is.
// or does not match either. One that cannot be read is recorded as failed // `storeOriginal` does this for one original, and records its failure.
// and left where it is. `storeOriginal` does this for one original, and
// records its failure.
const storeOriginal = async (file: EnteFile): Promise<void> => { const storeOriginal = async (file: EnteFile): Promise<void> => {
const stored = storedAtSavePath(downloadDirectory, file); let stored = storedAtSavePath(downloadDirectory, file);
let mismatch = false; let mismatch = false;
if (stored !== undefined && verify) { if (stored !== undefined && verify) {
try { try {
@@ -695,6 +706,11 @@ const runLockedBackup = async (
); );
mismatched++; mismatched++;
mismatch = true; mismatch = true;
rmSync(stored.path);
if (stored.videoPath !== undefined) {
rmSync(stored.videoPath);
}
stored = undefined;
} }
} catch (err) { } catch (err) {
log( log(
@@ -709,25 +725,29 @@ const runLockedBackup = async (
return; return;
} }
} }
if (stored !== undefined && !mismatch) { if (stored !== undefined) {
skipped++; skipped++;
return; return;
} }
try { try {
log(`Fetching original ${file.metadata.title} (${file.id})...`); log(`Fetching original ${file.metadata.title} (${file.id})...`);
if (mismatch) { // A fetched original is written straight to its save path (a
await replaceOriginal(downloadDirectory, file, (dest) => // live photo beside it); only one that was already cached
lib.fetchOriginal(file.id, dest), // elsewhere is copied.
); const placed = await placeOriginal(
} else { downloadDirectory,
// A fetched original is written straight to its save path (a file,
// live photo beside it); only one that was already cached (dest) => lib.original(file.id, dest),
// elsewhere is copied. );
await placeOriginal(downloadDirectory, file, (dest) => storedThisRun.add(file.id);
lib.original(file.id, dest), // A copy from the cache is not checked as a download is and can
// hold the same bad bytes, so what is put back after a mismatch
// is hashed too. A bad copy stays where it is and the file fails.
if (mismatch && (await storedHash(placed)) !== file.metadata.hash) {
throw new Error(
"the original put back does not match its content hash either",
); );
} }
storedThisRun.add(file.id);
downloaded++; downloaded++;
} catch (err) { } catch (err) {
log( log(
+2 -2
View File
@@ -394,11 +394,11 @@ export const backupMetadataCommand = async (
// Refresh first so the dump holds current account state, not what the // Refresh first so the dump holds current account state, not what the
// cache last held; a failed refresh throws. // cache last held; a failed refresh throws.
await lib.fresh(); await lib.fresh();
const result = await runMetadataBackup(lib, client, dir, { const { mlDataErrors } = await runMetadataBackup(lib, client, dir, {
exif: opts.exif || opts.all, exif: opts.exif || opts.all,
onProgress: (msg) => ctx.stderr.write(msg + "\n"), onProgress: (msg) => ctx.stderr.write(msg + "\n"),
}); });
return result.filesWithMLDataError > 0 ? 1 : 0; return mlDataErrors > 0 ? 1 : 0;
} finally { } finally {
await lib.close(); await lib.close();
} }
+6 -10
View File
@@ -199,13 +199,11 @@ const isRunning = (pid: number): boolean => {
}; };
// Delete the temp files a killed process left in `dir`: the writer's // Delete the temp files a killed process left in `dir`: the writer's
// `.quak-<pid>-<random>.tmp`, the backup copy's // `.quak-<pid>-<random>.tmp` and the backup copy's
// `.quak-backup-<name>-<pid>-<random>.tmp`, and the image and video of a live // `.quak-backup-<name>-<pid>-<random>.tmp`. Only files whose process is no
// photo downloaded to replace a stored one, `.quak-<pid>-<random>.<ext>` (see // longer running are removed, so another process writing into the same
// `replaceOriginal`). Only files whose process is no longer running are // directory keeps its own. A reused process ID can only keep a leftover a while
// removed, so another process writing into the same directory keeps its own. A // longer, never remove a live one.
// reused process ID can only keep a leftover a while longer, never remove a
// live one.
export const removeLeftoverTempFiles = (dir: string): void => { export const removeLeftoverTempFiles = (dir: string): void => {
let names: string[]; let names: string[];
try { try {
@@ -214,9 +212,7 @@ export const removeLeftoverTempFiles = (dir: string): void => {
return; return;
} }
for (const name of names) { for (const name of names) {
const match = /^\.quak-(?:.*-)?(\d+)-[0-9a-z]*\.[0-9A-Za-z]+$/.exec( const match = /^\.quak-(?:.*-)?(\d+)-[0-9a-z]*\.tmp$/.exec(name);
name,
);
if (match && !isRunning(Number(match[1]))) { if (match && !isRunning(Number(match[1]))) {
rmSync(join(dir, name), { force: true }); rmSync(join(dir, name), { force: true });
} }
+2 -116
View File
@@ -13,8 +13,6 @@
// originals, the thumbnail pool for thumbnails. The pool limits concurrency, // originals, the thumbnail pool for thumbnails. The pool limits concurrency,
// orders on-demand work ahead of background, and dedups by key so a fileID // orders on-demand work ahead of background, and dedups by key so a fileID
// requested twice while the first is still in flight downloads once. // requested twice while the first is still in flight downloads once.
// `fetchOriginal` passes no key, so it is not shared with a fetch of the same
// file already in flight.
// //
// Integrity. The reused streaming decrypt is the enforced guarantee: every // Integrity. The reused streaming decrypt is the enforced guarantee: every
// chunk is authenticated and the writer renames the file into place only once // chunk is authenticated and the writer renames the file into place only once
@@ -29,10 +27,9 @@
// It also names each original's save path under the download directory // It also names each original's save path under the download directory
// (`savePath`), where `Photo.download()` and `lib.backup()` put it. A copy in // (`savePath`), where `Photo.download()` and `lib.backup()` put it. A copy in
// the cache does not count as saved there, but is copied there rather than // the cache does not count as saved there, but is copied there rather than
// fetched again. An original stored there whose bytes do not match its content // fetched again.
// hash is replaced only by a download that does (`replaceOriginal`).
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs"; import { existsSync, readFileSync, statSync } from "node:fs";
import { import {
chmod, chmod,
copyFile, copyFile,
@@ -47,12 +44,6 @@ import {
import { basename, dirname, extname, join } from "node:path"; import { basename, dirname, extname, join } from "node:path";
import type { ApiClient } from "../api/client.js"; import type { ApiClient } from "../api/client.js";
import {
chunkHashFinal,
chunkHashInit,
chunkHashUpdate,
init,
} from "../crypto/index.js";
import { import {
downloadFile, downloadFile,
downloadThumbnail, downloadThumbnail,
@@ -358,26 +349,6 @@ export const storedAtSavePath = (
return storedOriginal(dirname(path), basename(path, extname(path)), file); return storedOriginal(dirname(path), basename(path, extname(path)), file);
}; };
// The content hash of the original stored at `stored`, computed as the download
// check computes it: over each file's bytes, read in chunks, and for a live
// photo `<imageHash>:<videoHash>`.
export const storedHash = async (stored: {
path: string;
videoPath?: string;
}): Promise<string> => {
await init();
const hashFile = async (path: string): Promise<string> => {
const state = chunkHashInit();
for await (const chunk of createReadStream(path)) {
chunkHashUpdate(state, chunk as Buffer);
}
return chunkHashFinal(state);
};
const hash = await hashFile(stored.path);
if (stored.videoPath === undefined) return hash;
return `${hash}:${await hashFile(stored.videoPath)}`;
};
// Copy bytes into `dest` via a temp file in the same directory plus rename, so // Copy bytes into `dest` via a temp file in the same directory plus rename, so
// `dest` appears only once it is whole ("present means complete"). As in the // `dest` appears only once it is whole ("present means complete"). As in the
// download writer, the temp file is fsynced before the rename and the directory // download writer, the temp file is fsynced before the rename and the directory
@@ -434,62 +405,6 @@ export const placeOriginal = async (
return { path, videoPath }; return { path, videoPath };
}; };
// Replace the original of `file` stored at its save path under `root` with a
// new copy, keeping the stored one until the copy is complete and its hash,
// computed as `storedHash` computes it, is the content hash the file's metadata
// records. `get` is given a temporary file beside the save path and writes the
// copy there, a live photo as its image and its video beside that file, each
// with its own extension. Only a copy that matches is renamed over the stored
// one, and a live photo's image and video are both hashed before either is
// renamed. The image is renamed first: should the run stop, or the video's
// rename fail, before the video is renamed, the new image is left beside the
// old video, and a later backup with `verify` replaces the video if the video
// was the part that did not match. When `get` fails or the copy does not match,
// the temporary files are removed and the stored original stays as it was.
export const replaceOriginal = async (
root: string,
file: EnteFile,
get: (dest: string) => Promise<{ path: string; videoPath?: string }>,
): Promise<{ path: string; videoPath?: string }> => {
const dest = savePath(root, file);
const dir = dirname(dest);
// Named as the download writer names its temporary files; a live photo's
// image and video get this name with their own extensions. The next
// backup removes any that a killed run leaves (`removeLeftoverTempFiles`).
const tmp = join(
dir,
`.quak-${process.pid}-${Math.random().toString(36).slice(2)}.tmp`,
);
let got: { path: string; videoPath?: string } | undefined;
try {
got = await get(tmp);
if ((await storedHash(got)) !== file.metadata.hash) {
throw new Error(
"the downloaded original does not match its content hash either",
);
}
if (got.videoPath === undefined) {
await rename(got.path, dest);
await fsyncPath(dir);
return { path: dest };
}
const path = withExtension(dest, extname(got.path));
const videoPath = withExtension(dest, extname(got.videoPath));
await rename(got.path, path);
await rename(got.videoPath, videoPath);
const name = basename(dest, extname(dest));
await writeLivePhotoJSON(dir, name, { path, videoPath });
return { path, videoPath };
} finally {
// Whatever was not renamed into place. A failure to remove it must
// not replace the error that explains what went wrong.
for (const path of [tmp, got?.path, got?.videoPath]) {
if (path === undefined) continue;
await rm(path, { force: true }).catch(() => undefined);
}
}
};
export class ContentCache implements PhotoContent, ThumbnailsAPI { export class ContentCache implements PhotoContent, ThumbnailsAPI {
private readonly pools: RequestPools; private readonly pools: RequestPools;
private readonly source: ContentSource; private readonly source: ContentSource;
@@ -628,35 +543,6 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
}; };
} }
// Fetch an original through the content pool to `destination`, a live
// photo as its image and its video beside it, and return where it landed.
// Unlike `backupOriginal`, it fetches even when the cache or the save path
// already holds the original, and it records nothing: what it writes is
// the caller's. It is not shared with a fetch of the same file already
// under way, which writes somewhere else.
async fetchOriginal(
fileID: number,
destination: string,
): Promise<{ path: string; videoPath?: string }> {
const file = this.getFile(fileID);
if (!file) throw new Error(`content cache: unknown file ${fileID}`);
return this.pools.content.run(
async () => {
const fetched = await this.fetchInto(
file,
destination,
"original",
undefined,
);
for (const path of [fetched.path, fetched.videoPath]) {
if (path !== undefined) await chmod(path, FILE_MODE);
}
return fetched;
},
{ priority: "on-demand" },
);
}
async ensure(args: EnsureOptions): Promise<EnsureResult[]> { async ensure(args: EnsureOptions): Promise<EnsureResult[]> {
return this.ensureThumbnails(args); return this.ensureThumbnails(args);
} }
+15 -26
View File
@@ -233,10 +233,9 @@ export interface LibraryStatus {
lastMLFetchAt?: number; lastMLFetchAt?: number;
// The most recent ML fetch pass's error, set only while it failed. // The most recent ML fetch pass's error, set only while it failed.
lastMLError?: string; lastMLError?: string;
// Each file whose ML data the last ML fetch pass that succeeded got but // Each file whose ML data the most recent ML fetch pass got but could not
// could not decrypt, gunzip or parse, with the reason. A pass still // decrypt, gunzip or parse, with the reason. Such ML data is not cached,
// running leaves it as it is. A pass that fails leaves it as it was. Such // so the next pass asks for it again.
// ML data is not cached, so the next pass asks for it again.
mlDataErrors: { fileID: number; error: string }[]; mlDataErrors: { fileID: number; error: string }[];
// ML payloads stored on disk and CLIP embeddings in the index; undefined // ML payloads stored on disk and CLIP embeddings in the index; undefined
// when ML fetching is disabled. // when ML fetching is disabled.
@@ -305,12 +304,10 @@ export class Library {
private lastError?: string; private lastError?: string;
private lastMLFetchAt?: number; private lastMLFetchAt?: number;
private lastMLError?: string; private lastMLError?: string;
// For each file whose ML data the last ML fetch pass that succeeded got // For each file whose ML data the most recent ML fetch pass got but could
// but could not decrypt, gunzip or parse, why not, by fileID. A pass // not decrypt, gunzip or parse, why not, by fileID. Kept in RAM only,
// gathers its own and puts them here when it ends without failing, so // never in the cache.
// `status()` and `backup()` never see the list of a pass that is still private readonly mlDataErrors = new Map<number, unknown>();
// running or that failed. Kept in RAM only, never in the cache.
private mlDataErrors = new Map<number, unknown>();
// The plain-record projection as of the last refresh, and the GUI change // The plain-record projection as of the last refresh, and the GUI change
// subscribers. A refresh that alters the projection notifies each with the // subscribers. A refresh that alters the projection notifies each with the
// delta; `lastRecords` is kept current every refresh so a subscriber that // delta; `lastRecords` is kept current every refresh so a subscriber that
@@ -626,8 +623,6 @@ export class Library {
undecryptable: () => this.store.listUndecryptable(), undecryptable: () => this.store.listUndecryptable(),
original: (fileID, destination) => original: (fileID, destination) =>
cache!.backupOriginal(fileID, destination), cache!.backupOriginal(fileID, destination),
fetchOriginal: (fileID, destination) =>
cache!.fetchOriginal(fileID, destination),
thumbnail: (fileID) => cache!.thumbnail(fileID), thumbnail: (fileID) => cache!.thumbnail(fileID),
contentConcurrency: this.pools.content.concurrency, contentConcurrency: this.pools.content.concurrency,
fetchMLData: () => this.fetchMLDataNow(), fetchMLData: () => this.fetchMLDataNow(),
@@ -860,10 +855,9 @@ export class Library {
// One ML fetch pass: fetch, decrypt and store the ML data for every file // One ML fetch pass: fetch, decrypt and store the ML data for every file
// the store knows about that is not cached (or whose `updationTime` has // the store knows about that is not cached (or whose `updationTime` has
// advanced), through the metadata pool, and update the CLIP index. For a // advanced), through the metadata pool, and update the CLIP index. For a
// file whose ML data does not decrypt, gunzip or parse, it gathers the // file whose ML data does not decrypt, gunzip or parse, it keeps the
// reason instead, and puts the reasons it gathered in `mlDataErrors` when // reason in `mlDataErrors` instead. A failure is reported through
// it ends without failing. A failure leaves `mlDataErrors` as it was and // `status()` and `onProgress`, then thrown.
// is reported through `status()` and `onProgress`, then thrown.
private async runMLFetch(): Promise<void> { private async runMLFetch(): Promise<void> {
const mldata = this.mlStore; const mldata = this.mlStore;
// Bind so the call keeps the client as its receiver when invoked // Bind so the call keeps the client as its receiver when invoked
@@ -873,14 +867,10 @@ export class Library {
const files = this.uniqueFiles(); const files = this.uniqueFiles();
const needed = mldata.neededFor(files); const needed = mldata.neededFor(files);
// This pass's reasons, put in `this.mlDataErrors` when it ends without // A file whose ML data did not decode in the pass before is still
// failing. A file whose ML data did not decode in the pass before is // among `needed`, so this pass gives its reason anew.
// still among `needed`, so this pass gives its reason anew. this.mlDataErrors.clear();
const mlDataErrors = new Map<number, unknown>(); if (needed.length === 0) return;
if (needed.length === 0) {
this.mlDataErrors = mlDataErrors;
return;
}
this.emit({ operation: "fetchMLData", status: "started" }); this.emit({ operation: "fetchMLData", status: "started" });
try { try {
@@ -900,13 +890,12 @@ export class Library {
{ priority: "background" }, { priority: "background" },
); );
for (const [id, error] of errors) { for (const [id, error] of errors) {
mlDataErrors.set(id, error); this.mlDataErrors.set(id, error);
} }
stored += (await mldata.storeFetched(payloads, updation)) stored += (await mldata.storeFetched(payloads, updation))
.stored; .stored;
} }
this.mlDataErrors = mlDataErrors;
this.lastMLFetchAt = Date.now(); this.lastMLFetchAt = Date.now();
this.lastMLError = undefined; this.lastMLError = undefined;
this.emit({ this.emit({
+2 -2
View File
@@ -100,7 +100,7 @@ export const runMetadataBackup = async (
client: Client, client: Client,
outDir: string, outDir: string,
opts?: MetadataBackupOptions, opts?: MetadataBackupOptions,
): Promise<{ filesWithMLDataError: number }> => { ): Promise<{ mlDataErrors: number }> => {
const log = opts?.onProgress ?? (() => {}); const log = opts?.onProgress ?? (() => {});
const wantExif = opts?.exif ?? false; const wantExif = opts?.exif ?? false;
@@ -235,5 +235,5 @@ export const runMetadataBackup = async (
} }
log("Metadata backup complete."); log("Metadata backup complete.");
return { filesWithMLDataError: mlDataErrors.size }; return { mlDataErrors: mlDataErrors.size };
}; };
+2 -11
View File
@@ -29,8 +29,7 @@ interface RawRemoteFileData {
} }
// Decrypt one entry with its file key and gunzip the JSON payload. Throws why // Decrypt one entry with its file key and gunzip the JSON payload. Throws why
// when the entry does not decrypt, gunzip or parse, or parses to something // when the entry does not decrypt, gunzip or parse.
// other than a JSON object, such as `null`, a number or an array.
const decodeEntry = (entry: RawRemoteFileData, key: Uint8Array): MLData => { const decodeEntry = (entry: RawRemoteFileData, key: Uint8Array): MLData => {
const decrypted = decryptBlob( const decrypted = decryptBlob(
fromBase64(entry.encryptedData), fromBase64(entry.encryptedData),
@@ -38,15 +37,7 @@ const decodeEntry = (entry: RawRemoteFileData, key: Uint8Array): MLData => {
key, key,
); );
const json = gunzipSync(Buffer.from(decrypted)).toString("utf-8"); const json = gunzipSync(Buffer.from(decrypted)).toString("utf-8");
const payload: unknown = JSON.parse(json); return JSON.parse(json) as MLData;
if (
typeof payload !== "object" ||
payload === null ||
Array.isArray(payload)
) {
throw new Error("not a JSON object");
}
return payload as MLData;
}; };
// Fetch ML data for up to `MLDATA_BATCH_SIZE` ids in a single request. This is // Fetch ML data for up to `MLDATA_BATCH_SIZE` ids in a single request. This is
+1 -78
View File
@@ -9,7 +9,7 @@
* data this file encrypts with libsodium, as Ente's clients encrypt it. So * 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 * 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 * hash its uploader recorded and unpacks a live photo, and quak's own retries
* repeat a failed request. Three tests run `lib.backup()` instead, as a program * repeat a failed request. Two tests run `lib.backup()` instead, as a program
* using quak as a library does. * using quak as a library does.
* *
* The account, user 42, has three albums: * The account, user 42, has three albums:
@@ -927,83 +927,6 @@ describe("quak backup", () => {
} }
}); });
// 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 // 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 // --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 // the hash Ente recorded, says so, and downloads it again; it put back the
+20 -83
View File
@@ -191,12 +191,9 @@ class HostileClient extends MockClient {
} }
// A content source that writes byte buffers of the expected length and can be // A content source that writes byte buffers of the expected length and can be
// told to fail one fileID's original, as a server error or as a 404, or to // told to fail one fileID's original, to exercise per-file resilience.
// write other bytes for it, to exercise per-file resilience.
interface StubSource extends ContentSource { interface StubSource extends ContentSource {
failID?: number; failID?: number;
notFoundID?: number;
otherBytesID?: number;
failThumbID?: number; failThumbID?: number;
originalCalls: number; originalCalls: number;
} }
@@ -207,10 +204,8 @@ const stubSource = (): StubSource => {
original: async ({ file: f, destination }) => { original: async ({ file: f, destination }) => {
s.originalCalls++; s.originalCalls++;
if (s.failID === f.id) throw new Error("HTTP 500 from server"); if (s.failID === f.id) throw new Error("HTTP 500 from server");
if (s.notFoundID === f.id) throw new Error("HTTP 404 from server");
const size = SIZE_BY_ID[f.id] ?? 10; const size = SIZE_BY_ID[f.id] ?? 10;
const fill = s.otherBytesID === f.id ? 2 : 0; writeFileSync(destination, Buffer.alloc(size));
writeFileSync(destination, Buffer.alloc(size, fill));
return { bytesWritten: size }; return { bytesWritten: size };
}, },
thumbnail: async ({ file: f, destination }) => { thumbnail: async ({ file: f, destination }) => {
@@ -701,26 +696,17 @@ describe("lib.backup", () => {
// A child that has already exited: its process ID is not running. // A child that has already exited: its process ID is not running.
const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid; const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid;
const leftover = `.quak-backup-2026-03-01.100.jpg-${exitedPID}-abc123.tmp`; const leftover = `.quak-backup-2026-03-01.100.jpg-${exitedPID}-abc123.tmp`;
// A live photo's image and video downloaded to replace a stored one,
// before they were renamed into place.
const replacement = [
`.quak-${exitedPID}-abc789.heic`,
`.quak-${exitedPID}-abc789.mov`,
];
// This test's own process stands in for a backup running at the same // This test's own process stands in for a backup running at the same
// time. // time.
const inProgress = `.quak-backup-2026-03-01.101.jpg-${process.pid}-def456.tmp`; const inProgress = `.quak-backup-2026-03-01.101.jpg-${process.pid}-def456.tmp`;
for (const name of [leftover, ...replacement, inProgress]) { writeFileSync(join(day, leftover), "partial");
writeFileSync(join(day, name), "partial"); writeFileSync(join(day, inProgress), "partial");
}
const lib = await openLibrary(stubSource()); const lib = await openLibrary(stubSource());
await lib.backup({ downloadDirectory: outDir }); await lib.backup({ downloadDirectory: outDir });
const names = readdirSync(day); const names = readdirSync(day);
expect(names).not.toContain(leftover); expect(names).not.toContain(leftover);
expect(names).not.toContain(replacement[0]);
expect(names).not.toContain(replacement[1]);
expect(names).toContain(inProgress); expect(names).toContain(inProgress);
lib.close(); lib.close();
}); });
@@ -1309,8 +1295,7 @@ describe("image metadata in each file's JSON", () => {
}); });
// With `verify`, each original already stored is hashed, and one that does not // With `verify`, each original already stored is hashed, and one that does not
// match the content hash its metadata records is downloaded again, and replaced // match the content hash its metadata records is downloaded again.
// only by a download that matches.
describe("backup with verify", () => { describe("backup with verify", () => {
// MockClient's files, each recording the hash of what `stubSource` writes // MockClient's files, each recording the hash of what `stubSource` writes
// for it, except diagram.png (200), which records none. // for it, except diagram.png (200), which records none.
@@ -1353,12 +1338,6 @@ describe("backup with verify", () => {
const good = Buffer.alloc(SIZE_BY_ID[100]!); const good = Buffer.alloc(SIZE_BY_ID[100]!);
const corrupt = Buffer.alloc(SIZE_BY_ID[100]!, 1); const corrupt = Buffer.alloc(SIZE_BY_ID[100]!, 1);
// The temporary files left in the date folder of the backup in `outDir`.
const tempFiles = (outDir: string): string[] =>
readdirSync(join(outDir, DAY)).filter((name) =>
name.startsWith(".quak-"),
);
it("leaves an original that matches its hash, and one with no hash, as they are", async () => { it("leaves an original that matches its hash, and one with no hash, as they are", async () => {
const { lib, source, outDir } = await backedUp(); const { lib, source, outDir } = await backedUp();
const calls = source.originalCalls; const calls = source.originalCalls;
@@ -1403,16 +1382,13 @@ describe("backup with verify", () => {
expect(log.filter((msg) => msg.startsWith("MISMATCH"))).toEqual([ expect(log.filter((msg) => msg.startsWith("MISMATCH"))).toEqual([
"MISMATCH original beach.jpg (100): its bytes do not match its content hash", "MISMATCH original beach.jpg (100): its bytes do not match its content hash",
]); ]);
expect(tempFiles(outDir)).toEqual([]);
await lib.close(); await lib.close();
}); });
// A copy whose hash does not match is still mostly the photo, so the it("records a failed download in failures.json, with the original removed", async () => {
// backup keeps it until it holds a better one.
it("keeps an original that does not match its hash when its download answers 404", async () => {
const { lib, source, outDir } = await backedUp(); const { lib, source, outDir } = await backedUp();
writeFileSync(saved(outDir, "100.jpg"), corrupt); writeFileSync(saved(outDir, "100.jpg"), corrupt);
source.notFoundID = 100; source.failID = 100;
const result = await lib.backup({ const result = await lib.backup({
downloadDirectory: outDir, downloadDirectory: outDir,
@@ -1425,18 +1401,18 @@ describe("backup with verify", () => {
failed: 1, failed: 1,
}); });
expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]); expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]);
expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(corrupt); expect(existsSync(saved(outDir, "100.jpg"))).toBe(false);
expect(
readlinkSync(join(outDir, "collections", "Vacation", "beach.jpg")),
).toBe(linkTo("100.jpg"));
expect(tempFiles(outDir)).toEqual([]);
await lib.close(); await lib.close();
}); });
it("keeps an original that does not match its hash when its download does not match either", async () => { it("records in failures.json an original the cache puts back with the same bad bytes", async () => {
const { lib, source, outDir } = await backedUp(); const lib = await openLibrary(stubSource(), new HashedClient());
writeFileSync(saved(outDir, "100.jpg"), corrupt); const outDir = join(root, "backup");
source.otherBytesID = 100; // The cache holds a bad copy, and a backup copies an original the
// cache holds to its save path.
const cached = await lib.photos.byID({ fileID: 100 })!.original();
writeFileSync(cached.path, corrupt);
await lib.backup({ downloadDirectory: outDir });
const result = await lib.backup({ const result = await lib.backup({
downloadDirectory: outDir, downloadDirectory: outDir,
@@ -1449,39 +1425,10 @@ describe("backup with verify", () => {
failed: 1, failed: 1,
}); });
expect(result.errors.map((e) => e.error)).toEqual([ expect(result.errors.map((e) => e.error)).toEqual([
"the downloaded original does not match its content hash either", "the original put back does not match its content hash either",
]); ]);
expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]); expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]);
expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(corrupt); expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(corrupt);
expect(tempFiles(outDir)).toEqual([]);
await lib.close();
});
// The cache can hold the same bad bytes, as a copy from it is not checked
// as a download is, so the replacement is always downloaded.
it("downloads the replacement even when the cache holds the original", async () => {
const source = stubSource();
const lib = await openLibrary(source, new HashedClient());
const outDir = join(root, "backup");
// The cache holds a bad copy, and a backup copies an original the
// cache holds to its save path.
const cached = await lib.photos.byID({ fileID: 100 })!.original();
writeFileSync(cached.path, corrupt);
await lib.backup({ downloadDirectory: outDir });
const calls = source.originalCalls;
const result = await lib.backup({
downloadDirectory: outDir,
verify: true,
});
expect(result).toMatchObject({
downloaded: 1,
mismatched: 1,
failed: 0,
});
expect(source.originalCalls).toBe(calls + 1);
expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(good);
await lib.close(); await lib.close();
}); });
@@ -1565,9 +1512,6 @@ describe("backup album folders", () => {
writeFileSync(path, `original ${fileID}`); writeFileSync(path, `original ${fileID}`);
return { path }; return { path };
}, },
fetchOriginal: async () => {
throw new Error("no verify in this stand-in");
},
thumbnail: async () => { thumbnail: async () => {
throw new Error("no thumbnails in this stand-in"); throw new Error("no thumbnails in this stand-in");
}, },
@@ -2302,12 +2246,11 @@ describe("backup of live photos", () => {
Buffer.from(IMAGE), Buffer.from(IMAGE),
); );
expect(readFileSync(video)).toEqual(Buffer.from(VIDEO)); expect(readFileSync(video)).toEqual(Buffer.from(VIDEO));
expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored);
expect(tree(outDir)).toEqual(linked); expect(tree(outDir)).toEqual(linked);
await lib.close(); await lib.close();
}); });
it("keeps both files of a live photo that does not match its hash when downloading it again fails", async () => { it("removes both files of a live photo that does not match its hash when downloading it again fails", async () => {
const { file: live, body } = await asLivePhoto( const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"), file(500, 10, "IMG_0500.HEIC"),
); );
@@ -2325,14 +2268,8 @@ describe("backup of live photos", () => {
expect(result).toMatchObject({ mismatched: 1, failed: 1 }); expect(result).toMatchObject({ mismatched: 1, failed: 1 });
expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]); expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]);
expect(readFileSync(saved(outDir, "500.heic"))).toEqual( expect(existsSync(saved(outDir, "500.heic"))).toBe(false);
Buffer.from(IMAGE), expect(existsSync(saved(outDir, "500.mov"))).toBe(false);
);
expect(readFileSync(saved(outDir, "500.mov"), "utf-8")).toBe(
"another few seconds of video",
);
expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored);
expect(tree(outDir)).toEqual(linked);
await lib.close(); await lib.close();
}); });
+3 -67
View File
@@ -13,10 +13,9 @@
* 2. `Library` wiring: after each refresh the library fetches ML data through * 2. `Library` wiring: after each refresh the library fetches ML data through
* the metadata pool for every known file not yet cached, is incremental on * the metadata pool for every known file not yet cached, is incremental on
* later refreshes, and refetches a file whose `updationTime` advanced. A * later refreshes, and refetches a file whose `updationTime` advanced. A
* file whose ML data did not decode is reported with the reason, which * file whose ML data did not decode is reported with the reason, and
* stays listed until the next pass ends, and asked for again. Opening a * asked for again. Opening a cache directory written by another account
* cache directory written by another account starts empty, its ML data * starts empty, its ML data included (issue #104).
* included (issue #104).
* *
* Embedding values are chosen to be exactly representable as float32 so the * Embedding values are chosen to be exactly representable as float32 so the
* round-trip through `clip.f32` compares equal. * round-trip through `clip.f32` compares equal.
@@ -527,69 +526,6 @@ describe("Library ML-data fetch on refresh", () => {
} }
}); });
it("still lists the last pass's reasons while the next pass runs", async () => {
const client = new MLMockClient();
client.collectionsQueue.push({
collections: [collection(1, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(1, {
files: [file(1002, 1, 95)],
deleted: [],
cursor: 95,
});
client.mlErrorByFile.set(
1002,
new Error("secretstream chunk authentication failed"),
);
const reasons = [
{ fileID: 1002, error: "secretstream chunk authentication failed" },
];
let release!: () => void;
const held = new Promise<void>((r) => (release = r));
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: Infinity,
});
try {
await vi.waitFor(
() => expect(lib.status().lastMLFetchAt).toBeGreaterThan(0),
{ timeout: 2000, interval: 5 },
);
expect(lib.status().mlDataErrors).toEqual(reasons);
// Hold the next ML data fetch open until the test releases it.
let fetchStarted!: () => void;
const started = new Promise<void>((r) => (fetchStarted = r));
const realFetch = client.fetchMLData.bind(client);
client.fetchMLData = async (args) => {
fetchStarted();
await held;
return realFetch(args);
};
client.mlErrorByFile.clear();
client.mlByFile.set(1002, payload([1, -2, 0.5]));
// The pass a refresh starts asks for 1002 again. While it is
// held, the reason from the pass before is still listed; once it
// ends, its own empty list takes the place of that one.
await lib.fresh();
await started;
expect(lib.status().mlDataErrors).toEqual(reasons);
release();
await vi.waitFor(
() => expect(lib.status().mlDataErrors).toEqual([]),
{ timeout: 2000, interval: 5 },
);
} finally {
release();
await lib.close();
}
});
it("close() resolves only after a running ML data fetch has stored its payloads", async () => { it("close() resolves only after a running ML data fetch has stored its payloads", async () => {
const client = new MLMockClient(); const client = new MLMockClient();
client.collectionsQueue.push({ client.collectionsQueue.push({
-54
View File
@@ -1,54 +0,0 @@
// `fetchMLDataBatch`, against a server that sends one file's ML data.
//
// ML data that decrypts and gunzips but is not a JSON object is reported with
// the reason, as ML data that does not parse is, and is never returned as a
// payload: the library would fail to store it.
import { gzipSync } from "node:zlib";
import sodium from "libsodium-wrappers-sumo";
import { beforeAll, describe, expect, it } from "vitest";
import { ApiClient } from "../../src/api/client.js";
import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js";
import { fetchMLDataBatch } from "../../src/mldata-fetch.js";
beforeAll(async () => {
await init();
await sodium.ready;
});
// Fetch the ML data of file 1 from a server that sends `json`, gzipped and
// encrypted with the file's key.
const fetchServing = async (json: string) => {
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { header, ciphertext } = encryptBlob(
new Uint8Array(gzipSync(json)),
key,
);
const data = [
{
fileID: 1,
encryptedData: toBase64(ciphertext),
decryptionHeader: toBase64(header),
},
];
const api = new ApiClient({
fetch: async () =>
new Response(JSON.stringify({ data }), {
headers: { "content-type": "application/json" },
}),
});
return fetchMLDataBatch(api, [1], new Map([[1, key]]));
};
describe("fetchMLDataBatch", () => {
it.each(["null", "5", "[]"])(
"gives a reason for ML data that is %s",
async (json) => {
const { payloads, errors } = await fetchServing(json);
expect(payloads.size).toBe(0);
expect(errors.get(1)).toEqual(new Error("not a JSON object"));
},
);
});