Compare commits

2 Commits
Author SHA1 Message Date
sneak e8c4c9e2ee quak backup no longer refreshes its library on a timer (closes #183)
check / check (push) Canceled after 0s
The backup opened its library with the default 3-second refresh interval,
so for its whole run it asked the server for changes every few seconds,
and each refresh started another ML data pass. It now opens it with
refreshIntervalSeconds: Infinity. The library takes Infinity as no
background timer, since setTimeout would turn it into 1 ms. A refresh
interval of 3600, as the read commands use, would still refresh once an
hour in a backup that runs for hours.

Model: opus-5-5
2026-10-14 00:29:01 +00:00
sneak d1b5d04661 Test: quak backup asks the server for changes only as it starts
check / check (push) Canceled after 0s
A backup whose single download is held open while the clock moves on two
hours must make only the two refreshes it waits for, counted at the fake
server's /collections/v2. It runs the real session loader and client
against a fake of the Ente HTTP API serving one encrypted album and photo.
This fails until the backup's library stops refreshing on a timer.

Model: opus-5-5
2026-10-14 00:18:58 +00:00
31 changed files with 670 additions and 4928 deletions
+73 -208
View File
@@ -19,10 +19,6 @@ encrypting and uploading them back to the server.
## Getting Started ## Getting Started
The commands below need git, node 22 or later, and yarn. `make install`, which
builds the single binary that "Running the backup from cron" uses, also needs
[nix](https://nixos.org/download).
```bash ```bash
git clone https://git.eeqj.de/sneak/quak.git git clone https://git.eeqj.de/sneak/quak.git
cd quak cd quak
@@ -90,12 +86,9 @@ enumeration/download calls) is exported too and documented under Design below.
## Running the backup from cron ## Running the backup from cron
`quak backup` never prompts, so cron can run it. `make install` builds quak as a `quak backup` never prompts, so cron can run it. `make install` builds quak as a
single binary and copies it to `~/bin/quak`. Besides what Getting Started needs, single binary and copies it to `~/bin/quak`. Log in once with it, as the user
it needs [nix](https://nixos.org/download), even where bun is already installed: the cron job will run as; the session is saved in that user's data directory
`make install` runs `make build-bin`, which bundles the built `dist/` with bun (see "Session handling"):
run through `nix-shell -p bun`. Log in once with `~/bin/quak`, as the user the
cron job will run as; the session is saved in that user's data directory (see
"Session handling"):
```bash ```bash
~/bin/quak login ~/bin/quak login
@@ -134,36 +127,19 @@ matters.
### Exit codes ### Exit codes
| Code | Meaning | | Code | Meaning |
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0` | The backup is complete: every original is at its save path, and no file failed. | | `0` | The backup is complete: every original is at its save path, and no file failed. |
| `1` | The run finished with files in `failures.json` or albums it could not decrypt or write (see "Backup layout"), or it stopped on another error, printed as one line `quak: <message>`. The next run tries again. | | `1` | The run finished with files in `failures.json`, or it stopped on another error, printed as one line `quak: <message>`. The next run tries again. |
| `2` | Another backup of the same directory is running. This run sent no request and changed nothing. | | `2` | Another backup of the same directory is running. This run sent no request and changed nothing. |
| `3` | There is no usable session: none is saved, the saved one is corrupt, or the server no longer accepts it. Run `quak login` as the user the cron job runs as. | | `3` | There is no usable session: none is saved, the saved one is corrupt, or the server no longer accepts it. Run `quak login` as the user the cron job runs as. |
`<message>` is the error's message, followed by the message of each error that
caused it. Under Node, as `yarn quak` runs quak, `fetch` reports every failure
below HTTP as just `fetch failed` and gives the reason as its cause, so a run
that could not reach the server prints, for example,
`quak: fetch failed: connect ECONNREFUSED 1.2.3.4:443`. When the server has
several addresses and none of them could be reached, the line gives the reason
for each, separated by `, `. A file whose download failed that way is in
`failures.json` with that error, as a transient failure. So is each file with no
ML data cached when the ML data fetch failed that way, its error starting
`ML data: `. The `~/bin/quak` that `make install` builds runs under bun, and
prints bun's own wording for such a failure, with no reason after it, such as
`quak: Unable to connect. Is the computer able to access the url?` for a refused
connection. It records such a failure in `failures.json` as transient too.
After a `1`, the next run fetches each missing original again, fetches the ML 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 original that a
decrypt, or a file in `failures.json` because quak could not decrypt it, keeps `--verify` run could not read, or put back with bytes that still do not match,
the exit code at `1` until the server sends a copy that decrypts, or deletes it stays at its save path. Only a later `--verify` run checks it again: a run
(see "Backup layout"). An original that a `--verify` run could not read, or without `--verify` leaves it as it is and takes the file out of `failures.json`,
could not replace with a download that matches, stays at its save path as it so that run can exit 0.
was. Only a later `--verify` run checks it again: a run without `--verify`
leaves it as it is and takes the file out of `failures.json`, so that run can
exit 0.
## Examples ## Examples
@@ -369,8 +345,7 @@ quak/
exif.ts EXIF read from an image's bytes with exifreader exif.ts EXIF read from an image's bytes with exifreader
mldata-fetch.ts fetch + decrypt per-file ML data mldata-fetch.ts fetch + decrypt per-file ML data
filename.ts safe file names from server metadata filename.ts safe file names from server metadata
errors.ts error types shared across layers, and how an error errors.ts error types shared across layers
is described
retry.ts retry classifier + exponential backoff with jitter retry.ts retry classifier + exponential backoff with jitter
thumbnails.ts detect + regenerate missing thumbnails thumbnails.ts detect + regenerate missing thumbnails
client.ts high-level Client class assembled from the above client.ts high-level Client class assembled from the above
@@ -497,17 +472,6 @@ request is repeated only when repeating it could produce a different answer:
declining to retry a transient one costs a single file that the next run picks declining to retry a transient one costs a single file that the next run picks
up. up.
The `~/bin/quak` that `make install` builds runs under bun, whose `fetch`
rejects with a plain `Error` that has no `cause` and carries a code of bun's
own. quak retries a refused connection, a name that does not resolve, a reset
connection and a deadline under bun as it does under Node. Bun reports a refused
connection, and a TLS handshake that failed, as `ConnectionRefused`. For a name
that does not resolve it gives `ConnectionRefused` on some requests and
`FailedToOpenSocket` on others. A connection reset or closed before the response
ended is `ECONNRESET`, as under Node, and a deadline is the same `TimeoutError`.
A certificate bun rejects, reported with a code such as
`DEPTH_ZERO_SELF_SIGNED_CERT`, is not retried.
Backoff is exponential with full jitter: the delay before retry _n_ is Backoff is exponential with full jitter: the delay before retry _n_ is
`random() * min(maxDelayMs, baseDelayMs * 2 ** (n - 1))`. The exponential term `random() * min(maxDelayMs, baseDelayMs * 2 ** (n - 1))`. The exponential term
is the ceiling and the wait is drawn below it, so a client that lost many is the ceiling and the wait is drawn below it, so a client that lost many
@@ -563,13 +527,9 @@ routing errnos `EHOSTUNREACH`, `ENETUNREACH` and `ENETDOWN` are excluded for the
same reason, despite looking like connect-time failures: on Linux an ICMP same reason, despite looking like connect-time failures: on Linux an ICMP
unreachable arriving mid-flight, or a local interface going down after the unreachable arriving mid-flight, or a local interface going down after the
request was written, delivers them on an already-established socket. They stay request was written, delivers them on an already-established socket. They stay
retryable for the idempotent calls. So do bun's `ConnectionRefused` and retryable for the idempotent calls. `putFile` is exempt: a presigned PUT stores
`FailedToOpenSocket`, on which a `POST` or `PUT` is not replayed either. When a one whole object at one key in one request, so replaying it has no partial state
connection bun kept open from an earlier request closes before any answer, bun to damage.
sends the request again on a new connection, and if that one fails it gives one
of these codes although the server may already have the request. `putFile` is
exempt: a presigned PUT stores one whole object at one key in one request, so
replaying it has no partial state to damage.
A download is retried as a whole — request, stream consumption, and decryption — A download is retried as a whole — request, stream consumption, and decryption —
because a socket reset after the response headers have arrived surfaces in the because a socket reset after the response headers have arrived surfaces in the
@@ -663,17 +623,8 @@ library. The read commands — `collections`, `files`, `get`, `get-thumb`,
answer, so they report current account state rather than whatever the cache last answer, so they report current account state rather than whatever the cache last
held. If that round-trip fails, the command prints the error on one line and held. If that round-trip fails, the command prints the error on one line and
exits 1, or 3 when the server no longer accepts the saved session (see "Session exits 1, or 3 when the server no longer accepts the saved session (see "Session
handling"). An album or file quak cannot decrypt does not fail it; the command handling"). `--cache-dir` overrides where the cache lives; without it each
answers from what did decrypt (see "Default reads vs. fresh reads"). account gets its own directory under the per-user cache path.
`--cache-dir` overrides where the cache lives; without it each account gets its
own directory under the per-user cache path.
An ID given to `files --collection`, `get`, `get-thumb` or
`helper fix-missing-thumbnails --file` is written in decimal digits, no larger
than 9007199254740991. Anything else, such as `0x10`, `1e3` or `-1`, is refused
before any request with one line, `Invalid file ID: <arg>` or
`Invalid collection ID: <arg>`, and exit status 1. One bad `--file` ID refuses
the whole command, so nothing is fixed.
`get` and `get-thumb` resolve the file by ID directly, so `--collection` is `get` and `get-thumb` resolve the file by ID directly, so `--collection` is
accepted for backward compatibility but ignored. For a live photo, `get` writes accepted for backward compatibility but ignored. For a live photo, `get` writes
@@ -693,15 +644,13 @@ 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
`mlDataError` field instead of `mlData`, and the dump goes on. A file whose ML `mlDataError` field instead of `mlData`, and the dump goes on. The exit code is
data does not decrypt, gunzip or parse is logged and written the same way, with non-zero if any ML data request failed.
why in `mlDataError`. The exit code is non-zero if any file was written with
`mlDataError`.
`helper fix-missing-thumbnails` regenerates thumbnails for JPEG images only, `helper fix-missing-thumbnails` regenerates thumbnails for JPEG images only,
because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG image because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG image
@@ -766,15 +715,8 @@ A file's JSON holds Ente's ML data for it (its faces and its CLIP embedding) as
for has no `mlData`. The backup waits for the library's ML data fetch to finish for has no `mlData`. The backup waits for the library's ML data fetch to finish
before it writes the JSON files. If that fetch fails, each file whose ML data is before it writes the JSON files. If that fetch fails, each file whose ML data is
not in the cache gets the reason in `mlDataError` instead and counts as failed, not in the cache gets the reason in `mlDataError` instead and counts as failed,
and the next run fetches it again. The same goes for a file with no ML data in and the next run fetches it again. The JSON files are rewritten on every run, so
the cache whose ML data the server sent but quak could not decrypt, gunzip or ML data that arrived since the last run appears.
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
for the file, so the next run asks for its ML data again. An ML data fetch that
fails, such as one the library's refresh starts while the backup writes the JSON
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`
@@ -792,25 +734,12 @@ succeeds, or once it is no longer in the library or in the backup's scope. The
library's `lib.backup({ includeThumbnails: true })` also writes library's `lib.backup({ includeThumbnails: true })` also writes
`thumbnails/<fileID>.jpg` beside `collections/`; `quak backup` does not. `thumbnails/<fileID>.jpg` beside `collections/`; `quak backup` does not.
A file in the backup's scope that the library could not decrypt (see "Default
reads vs. fresh reads") goes into `failures.json` with an error that starts
`could not be decrypted:` and gives the reason. If a copy of it in another album
decrypted, it does not: its original is stored from that copy, and the album
where it does not decrypt neither links nor lists it. An album the library could
not decrypt has no name quak can read and no files it can list. A run without
`--json` prints one line for it,
`FAILED album <id>: could not be decrypted: <reason>`, and `--json` and the
`BackupResult` list its ID in `undecryptableAlbums`. Such a file in
`failures.json`, or such an album, keeps the exit code at 1, run after run,
until the server sends a copy that decrypts or deletes it; the next run then
drops it.
`backup.lock` keeps two backups of the same directory from running at once, such `backup.lock` keeps two backups of the same directory from running at once, such
as a cron run that starts while the previous one is still going. A backup as a cron run that starts while the previous one is still going. A backup
creates `<dir>` if it is missing and takes the lock before its refresh, and creates `<dir>` if it is missing and takes the lock before its refresh, and
removes the lock when it ends, whether it succeeds or fails, unless it lost the removes the lock when it ends, whether it succeeds or fails; `quak backup` takes
lock (see below); `quak backup` takes it before it opens its library, so a it before it opens its library, so a refused run sends no request. The lock is a
refused run sends no request. The lock is a directory that directory that
[proper-lockfile](https://github.com/moxystudio/node-proper-lockfile) creates [proper-lockfile](https://github.com/moxystudio/node-proper-lockfile) creates
and keeps touching while the backup runs. A second backup of the directory, from and keeps touching while the backup runs. A second backup of the directory, from
another process or the same one, fails at once: `quak backup` prints another process or the same one, fails at once: `quak backup` prints
@@ -822,38 +751,13 @@ over, so nobody has to remove it. The lock is outside the date folders and
`collections/`, so it is never taken for an original, and the removal of old `collections/`, so it is never taken for an original, and the removal of old
album directories never touches it. album directories never touches it.
A running backup loses its lock when someone removes `backup.lock`, or when
another backup takes it over because the running one left it untouched for 10
seconds, as a stopped or stalled process does. The backup finds this out the
next time it goes to touch the lock, which it does every 5 seconds, and then
stops before it starts another file. The originals it is downloading at that
moment are still stored. It leaves `failures.json` and `backup.lock` as they
are, since another backup may be using them. `quak backup` prints
`quak: lost the lock on <dir>: <reason>` as one line and exits 1, and
`lib.backup()` rejects with `lost the lock on <dir>: <reason>`.
A collection's directory and JSON are named after the collection, and a symlink A collection's directory and JSON are named after the collection, and a symlink
after the file's title, both with unsafe characters replaced. When two after the file's title, both with unsafe characters replaced. When two
collections would get the same name, or two symlinks in one collection the same collections would get the same name, or two symlinks in one collection the same
name (ignoring case in both), each of them gets its ID added: two albums named name (ignoring case in both), each of them gets its ID added: two albums named
`Trip` become `Trip (10)/` and `Trip (11)/`, and two files titled `IMG_0001.JPG` `Trip` become `Trip (10)/` and `Trip (11)/`, and two files titled `IMG_0001.JPG`
become `IMG_0001 (12345).JPG` and `IMG_0001 (12346).JPG`. A collection whose become `IMG_0001 (12345).JPG` and `IMG_0001 (12346).JPG`. IDs never change, so a
directory would have the name of another collection's JSON, again ignoring case, name stays the same from run to run until such a clash appears or goes away.
gets its ID added as well: beside an album `x`, whose JSON is `x.json`, an album
named `x.json` gets the directory `x.json (12)/`. A name too long for a file
name, which most file systems limit to 255 bytes, gets its ID added too. A name
with its ID added is cut short between two characters in the part before the ID,
just far enough that it fits. When a symlink's extension alone leaves no room
for the ID, the extension is cut short and the part before the ID keeps only the
bytes that cut leaves over. A collection's directory name also leaves room for
the `.json` its JSON adds. IDs never change, so a name stays the same from run
to run until such a clash appears or goes away.
A collection whose directory or JSON cannot be written, such as one whose
directory name is taken by a file you put in `collections/`, does not stop the
run. A run without `--json` prints `FAILED album <name>: <reason>` for it,
`--json` and the `BackupResult` list its ID in `failedAlbums`, and the exit code
is 1. The other collections and `failures.json` are still written.
A live photo, which Ente stores as one ZIP of its image and its video, is stored A live photo, which Ente stores as one ZIP of its image and its video, is stored
as those two files, which a photo viewer can open: each is as those two files, which a photo viewer can open: each is
@@ -866,12 +770,9 @@ both, each named after the title with that file's extension (`IMG_0001.heic` and
Each run removes the symlinks into the date folders that no longer belong in Each run removes the symlinks into the date folders that no longer belong in
their collection's directory, and the directories (and JSON) of collections that their collection's directory, and the directories (and JSON) of collections that
were deleted or renamed. An album quak can no longer decrypt loses its directory were deleted or renamed. Nothing else in `collections/` is touched: a file or a
and JSON the same way, and a file whose copy in an album quak can no longer symlink you put there stays, and a directory that still holds one after its
decrypt loses its symlink there and its entry in that album's JSON; the symlinks are removed stays too, with its JSON.
originals stay at their save paths. Nothing else in `collections/` is touched: a
file or a symlink you put there stays, and a directory that still holds one
after its symlinks are removed stays too, with its JSON.
Each file is downloaded exactly once regardless of how many collections it Each file is downloaded exactly once regardless of how many collections it
appears in, and written once: straight to its save path, with no copy left in appears in, and written once: straight to its save path, with no copy left in
@@ -888,41 +789,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
@@ -951,8 +844,7 @@ The library's primary surface is the cache-backed `Library`; the lower-level
`Client` sits underneath it and is covered by the Design sections above. The `Client` sits underneath it and is covered by the Design sections above. The
test suite is the canonical, executable documentation — `test/library/` and test suite is the canonical, executable documentation — `test/library/` and
`test/client/usage.test.ts` walk most operations, `test/cli/backup.test.ts` `test/client/usage.test.ts` walk most operations, `test/cli/backup.test.ts`
walks `lib.backup()`, `test/cli/backup-end-to-end.test.ts` runs `quak backup` walks `lib.backup()`, and `yarn test` verifies them.
against a fake of the Ente HTTP API, and `yarn test` verifies them.
### Opening a library ### Opening a library
@@ -996,14 +888,6 @@ resolves once that refresh (including its cache write), the ML data fetch and
the precache fetches already running have all finished, so the cache directory 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
data the last ML data fetch that succeeded got but could not decrypt, gunzip or
parse; the `fetchMLData` event with status `"done"` carries the same list. A
fetch that is still running leaves the list as it is until it ends, and a fetch
that fails leaves it as it was. Such ML data is not cached, so the next ML data
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
Default reads — `lib.albums`, `lib.photos`, `lib.timeline` — answer Default reads — `lib.albums`, `lib.photos`, `lib.timeline` — answer
@@ -1023,16 +907,6 @@ rejects the caller (default reads stay silent and keep serving the last good
copy). The CLI's read commands use fresh reads (issue copy). The CLI's read commands use fresh reads (issue
https://git.eeqj.de/sneak/quak/issues/75). https://git.eeqj.de/sneak/quak/issues/75).
An album or file in the server's answer that quak cannot decrypt, or whose
metadata is not JSON, does not fail the refresh. The refresh leaves it out, an
album with all its files, and drops any older copy the cache held, so reads
answer from what did decrypt. The library keeps a list of what it left out, with
the reason, in `metadata.json`: the next refresh asks the server only for what
changed, so it would not see the record again. A record stays on that list until
a later refresh gets a copy that decrypts or learns that the server deleted it.
`lib.backup()` reports each such album, and each such file in its scope that has
no copy that decrypted (see "Backup layout").
### Read surface ### Read surface
- `lib.albums.list()` → `Album[]`, newest-updated first. - `lib.albums.list()` → `Album[]`, newest-updated first.
@@ -1148,25 +1022,21 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
directory, and fails at once with an error whose `code` is `ELOCKED` while directory, and fails at once with an error whose `code` is `ELOCKED` while
another backup of it runs. A caller can instead take the lock itself, before another backup of it runs. A caller can instead take the lock itself, before
it opens its library, as `quak backup` does: `await lockBackupDirectory(dir)` it opens its library, as `quak backup` does: `await lockBackupDirectory(dir)`
takes it, failing the same way, and returns a `BackupLock`, whose `release()` takes it, failing the same way, and returns the function that releases it, and
removes the lock unless it was lost and whose `lost` holds why it was lost. the caller passes `lockHeld: true` to the backup. It waits for a refresh as
The caller passes it to the backup as `lock`, and releases it once the backup `fresh()` does, puts every in-scope original not already at its save path
ends. It waits for a refresh as `fresh()` does, puts every in-scope original there as `photo.download()` does (and, with `includeThumbnails`, fetches
not already at its save path there as `photo.download()` does (and, with thumbnails) through the content cache, waits for an ML data fetch, and
`includeThumbnails`, fetches thumbnails) through the content cache, waits for rebuilds the on-disk backup tree with a durable failure ledger. Each file's
an ML data fetch, and rebuilds the on-disk backup tree with a durable failure JSON holds its ML data and, for an image (for a live photo, its image), its
ledger. Each file's JSON holds its ML data and, for an image (for a live original's EXIF and XMP, with its dimensions for a JPEG only; a video's JSON
photo, its image), its original's EXIF and XMP, with its dimensions for a JPEG holds none of these three. A fetched original is written straight to its save
only; a video's JSON holds none of these three. A fetched original is written path and not into the cache, which then counts it as present; one the cache
straight to its save path and not into the cache, which then counts it as already held is copied from there. `BackupOptions`: `downloadDirectory` (falls
present; one the cache already held is copied from there. `BackupOptions`: back to the library's), `includeOriginals` (default `true`),
`downloadDirectory` (falls back to the library's), `includeOriginals` (default `includeThumbnails` (default `false`), `onlyAlbumNames`, `verify` (default
`true`), `includeThumbnails` (default `false`), `onlyAlbumNames`, `verify` `false`), `onProgress`, and `lockHeld` (default `false`). See Backup layout
(default `false`), `onProgress`, and `lock`. See Backup layout above for the above for the tree it writes.
tree it writes, and for the albums `BackupResult` lists in
`undecryptableAlbums` and `failedAlbums`. Whether the backup took the lock or
its caller did, once the lock is lost the backup rejects with
`lost the lock on <dir>: <reason>` before it starts another file.
### Request pools ### Request pools
@@ -1175,11 +1045,6 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
Each pool orders on-demand work ahead of background/precache work and dedups Each pool orders on-demand work ahead of background/precache work and dedups
in-flight fetches by key, and an idle pool never lends its slots to a busy one. in-flight fetches by key, and an idle pool never lends its slots to a busy one.
`lib.backup()`, and so `quak backup`, works on up to the content pool's limit of
originals at once, 5 unless the library was opened with other `pools`. The limit
covers everything it does for one original, hashing it for `verify` included, so
however large the account is, no more originals than that are open at once.
### On-disk cache layout ### On-disk cache layout
Under `cacheDirectory`: Under `cacheDirectory`:
@@ -1231,7 +1096,7 @@ from a very old client, is stored unchecked.
- `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult` - `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult`
- `src/exif.ts`: `ExifTags`, `PhotoExif` - `src/exif.ts`: `ExifTags`, `PhotoExif`
- `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool` - `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool`
- `src/backup.ts`: `BackupLock`, `BackupOptions`, `BackupResult`, `BackupError` - `src/backup.ts`: `BackupOptions`, `BackupResult`, `BackupError`
- `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot` - `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot`
- `src/api/client.ts`: `ApiClient`, `ApiClientOptions`, `StreamOptions` - `src/api/client.ts`: `ApiClient`, `ApiClientOptions`, `StreamOptions`
- `src/errors.ts`: `ApiError`, `TruncatedStreamError` - `src/errors.ts`: `ApiError`, `TruncatedStreamError`
+2 -122
View File
@@ -17,114 +17,14 @@ pre-1.0
# Next Step # Next Step
One bug is open: https://git.eeqj.de/sneak/quak/issues/205. The check of quak None: no implementation work is open. The cache design,
against a live Ente account, https://git.eeqj.de/sneak/quak/issues/187, needs an https://git.eeqj.de/sneak/quak/issues/36, waits on sneak's review.
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.
# Completed Steps # Completed Steps
- 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
each such file beside the payloads; ML data that parses to something other
than a JSON object, such as `null`, is one of them. The library keeps the
reasons from its last ML data fetch that succeeded in memory, not in the
cache, and gives them in `status().mlDataErrors` and in the `fetchMLData`
event with status `"done"`; a fetch still running or one that fails does not
change them. `quak backup` writes the reason as `mlDataError` in the file's
JSON when no ML data is cached for it, and records the file in `failures.json`
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
connection and a name that does not resolve, and the backup records either as
transient (issue 206). Bun's `fetch` reports them with codes of its own,
`ConnectionRefused` and `FailedToOpenSocket`, which `src/retry.ts` now counts
as transport failures. A `POST` or `PUT` is not replayed on them, because bun
also gives them when it resends a request on a new connection after a
connection it kept open closed, and the server may already have the request.
Bun already gave `ECONNRESET` for a reset and a `TimeoutError` for a deadline.
`classify` in `src/backup.ts` now records any `TimeoutError` as transient, so
a stalled download, whose error says only that it stalled, is no longer
`unknown`. The shapes were found by running the binary against local
listeners.
- 2026-10-15: An album whose folder or JSON `quak backup` cannot write no longer
ends the run before the other albums and `failures.json` are written (issue
197). The run prints `FAILED album <name>: <reason>`, lists the album's ID in
`failedAlbums` in the result and in `--json`, goes on, and exits 1. An album
whose folder would have the name of another album's JSON, as `x.json` beside
`x` would, gets its ID added. A folder or link name over 255 bytes is cut
short between two characters, by `shortenToBytes` in `src/filename.ts`, and
gets the ID.
- 2026-10-15: A network failure is printed and recorded with its reason, not as
just `fetch failed` (issue 196). `describeError` in `src/errors.ts` gives an
error's message followed by the message of each error on its `cause` chain,
such as `fetch failed: connect ECONNREFUSED 1.2.3.4:443`, and each address's
reason when a host has several and none could be reached. `run` prints that,
and the backup logs it and records it in `failures.json`, where `classify` now
calls the failure transient when the chain holds an errno that
`hasTransportCode` in `src/retry.ts` recognises, for a failed ML data fetch
too. The walk along the chain moved from `src/retry.ts` to `src/errors.ts`,
which both use. Tests refuse every connection of a backup, then only one
file's download, and fail the ML data fetch.
- 2026-10-15: The README says that `make install` needs nix (issue 200), in
Getting Started and in "Running the backup from cron": `make build-bin` runs
bun through `nix-shell -p bun`. The `Makefile` is unchanged.
- 2026-10-14: `lib.backup()`, and so `quak backup`, works on up to the content
pool's limit of originals at once, 5 by default, instead of one at a time
(issue 182). That many workers take files from one shared list, each doing all
the work for one original, its `--verify` hashing included, so no more
originals than that are open at once. `BackupLibrary` says the limit as
`contentConcurrency`. A test holds each download at the fake Ente server to
count those in flight. Once a worker has thrown, as one does when `onProgress`
throws, or the lock is lost, no worker takes another file; the backup fails
once the originals under way are in place, and a lost lock stops it before it
fetches ML data.
- 2026-10-14: Next Step said the cache design, issue 36, waits on sneak's
review, though that issue closed on 2026-10-03 (issue 194). It now names the
issues open on the tracker.
- 2026-10-14: A backup whose lock is lost no longer dies with a stack trace
(issue 184). `lockBackupDirectory` gives proper-lockfile an `onCompromised`
that records why the lock was lost instead of throwing from its timer, and
returns the lock, which `quak backup` passes to `lib.backup()` as `lock`. The
backup then stops before it starts another file with
`lost the lock on DIR: REASON`, which `quak backup` prints as one line before
it exits 1. Releasing a lost lock leaves `backup.lock` alone. Tests in
`test/cli/backup-end-to-end.test.ts` remove the lock, or put another backup's
in its place, while a download is held open.
- 2026-10-14: An album or file quak cannot decrypt, or whose metadata is not
JSON, no longer fails the refresh, so one bad record no longer stops
`quak backup` from storing anything (issue 181). The refresh leaves it out, an
album with its files, and the library keeps it with the reason in
`metadata.json` until the server sends a copy that decrypts or deletes it.
`quak backup` stores every other file, records each such file in
`failures.json` with an error starting `could not be decrypted:`, names each
such album's ID in a printed line, or with `--json` in `undecryptableAlbums`,
and exits 1 while any remain. A file whose copy in another album decrypts is
not recorded: its original is stored from that copy, and the album where it
does not decrypt neither links nor lists it. The read commands answer from
what decrypted.
- 2026-10-14: `quak backup` no longer asks the server for changes every 3 - 2026-10-14: `quak backup` no longer asks the server for changes every 3
seconds while it runs (issue 183). It opens its library with seconds while it runs (issue 183). It opens its library with
`refreshIntervalSeconds: Infinity`, which the library now takes as no `refreshIntervalSeconds: Infinity`, which the library now takes as no
@@ -132,26 +32,6 @@ declares one.
refresh starts another ML data pass during the run. A test holds a backup's refresh starts another ML data pass during the run. A test holds a backup's
download open while two hours pass and counts its `/collections/v2` requests. download open while two hours pass and counts its `/collections/v2` requests.
- 2026-10-14: `test/cli/backup-end-to-end.test.ts` runs `quak backup` as cron
does (issue 186): `backupCommand` and `run`, the real `loadSession` reading a
session saved after logging in, and every request answered by a fake of the
Ente HTTP API serving data encrypted with libsodium. It covers the backup tree
of an account with two albums of its own and one shared with it, a second run
that downloads nothing, a download that succeeds after two 503s, a 404 that
lands in `failures.json` with exit 1, a refresh answered 401 with exit 3, and
`--verify` putting back an original changed on disk. The fake server and the
account it logs in moved from the backup-metadata test into
`test/ente-server.ts`, unchanged.
- 2026-10-14: `files --collection`, `get`, `get-thumb` and
`helper fix-missing-thumbnails --file` take an ID only when it is written in
decimal digits and is a safe integer (issue 185). Before, `Number()` read
`0x10` as 16, `1e3` as 1000 and an empty string as 0, and `--file` dropped an
ID it could not read without a word. Anything else now prints
`Invalid file ID: <arg>` or `Invalid collection ID: <arg>` and exits 1 before
any request; one bad `--file` ID fails the whole command. `parseID` in
`src/cli-commands.ts` does the check for all four.
- 2026-10-06: The README says how to run `quak backup` from cron (issue 170): - 2026-10-06: The README says how to run `quak backup` from cron (issue 170):
log in once as the job's user, a crontab with a backup every night and log in once as the job's user, a crontab with a backup every night and
`--verify` on Sundays appending to a log file, and the `HOME` and `--verify` on Sundays appending to a log file, and the `HOME` and
+206 -356
View File
@@ -1,14 +1,13 @@
// The backup command, rebuilt on the library API (issue #51). // The backup command, rebuilt on the library API (issue #51).
// //
// `lib.backup()` takes the lock in `downloadDirectory` (unless its caller // `lib.backup()` takes the lock in `downloadDirectory` (unless its caller
// passes one in), and fails at once when another backup of it holds the // already holds it), and fails at once when another backup of it holds the
// lock; when the lock is lost while it runs, it fails before it starts another // lock. It waits for a completed refresh of the library (a failed one fails the
// file. It waits for a completed refresh of the library (a failed one fails the
// backup before any file is touched), then, for every file in scope, puts its // backup before any file is touched), then, for every file in scope, puts its
// original at its save path under `downloadDirectory`, as `Photo.download()` // original at its save path under `downloadDirectory`, as `Photo.download()`
// does, up to the content pool's limit of them at once, waits for an ML data // does, waits for an ML data fetch, and rebuilds the derived views (per-file
// fetch, and rebuilds the derived views (per-file sidecars, per-collection // sidecars, per-collection symlink trees, per-collection JSON) from the model.
// symlink trees, per-collection JSON) from the model. The on-disk layout: // The on-disk layout:
// //
// <downloadDirectory>/ // <downloadDirectory>/
// YYYY/YYYY-MM/YYYY-MM-DD/ // YYYY/YYYY-MM/YYYY-MM-DD/
@@ -41,19 +40,13 @@
// //
// 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
// failed is caught, recorded in `failures.json` with a classification, a // failed is caught, recorded in `failures.json` with a classification, a
// running attempt count, and the last-tried time, and the run continues. // running attempt count, and the last-tried time, and the run continues.
// A file the library could not decrypt is recorded there too, unless a copy of
// it in another album decrypted; an album it could not decrypt is logged by ID
// and listed in `result.undecryptableAlbums`, as its name is not known. An
// album whose folder or JSON cannot be written is logged by name and
// listed in `result.failedAlbums`, and the run goes on to the next album.
// `result.failed` — and thus the CLI's exit code — stays non-zero while any // `result.failed` — and thus the CLI's exit code — stays non-zero while any
// failure remains unresolved and clears once every one succeeds. Each run // failure remains unresolved and clears once every one succeeds. Each run
// reconciles the ledger against the files it attempted, so an entry for a file // reconciles the ledger against the files it attempted, so an entry for a file
@@ -62,6 +55,7 @@
// code. // code.
import { import {
createReadStream,
lstatSync, lstatSync,
mkdirSync, mkdirSync,
readdirSync, readdirSync,
@@ -77,39 +71,27 @@ 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 { removeLeftoverTempFiles } from "./download/index.js";
import { describeError } from "./errors.js";
import { import {
MAX_NAME_BYTES, chunkHashFinal,
sanitizeFileName, chunkHashInit,
shortenToBytes, chunkHashUpdate,
withExtension, init,
} from "./filename.js"; } from "./crypto/index.js";
import { removeLeftoverTempFiles } from "./download/index.js";
import { sanitizeFileName, withExtension } from "./filename.js";
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 { extractImageMetadata } from "./metadata-backup.js"; import { extractImageMetadata } from "./metadata-backup.js";
import type { MLData } from "./mldata-fetch.js"; import type { MLData } from "./mldata-fetch.js";
import type { Collection, EnteFile, FileMetadata } from "./model/types.js"; import type { Collection, EnteFile, FileMetadata } from "./model/types.js";
import { hasTransportCode } from "./retry.js";
export type ProgressCallback = (message: string) => void; export type ProgressCallback = (message: string) => void;
// The lock in a backup directory, as `lockBackupDirectory` returns it.
export interface BackupLock {
// Removes `backup.lock`, unless the lock was lost.
release(): Promise<void>;
// Why the lock was lost, once it is.
lost?: Error;
}
export interface BackupOptions { export interface BackupOptions {
// Where the backup tree lives. `lib.backup()` defaults it to the library's // Where the backup tree lives. `lib.backup()` defaults it to the library's
// download directory; `runBackup` with none throws before any network // download directory; `runBackup` with none throws before any network
@@ -126,11 +108,10 @@ export interface BackupOptions {
// bytes do not match the content hash its metadata records. Default false. // bytes do not match the content hash its metadata records. Default false.
verify?: boolean; verify?: boolean;
onProgress?: ProgressCallback; onProgress?: ProgressCallback;
// The lock in `downloadDirectory`, when the caller took it with // The caller already holds the lock in `downloadDirectory`, taken with
// `lockBackupDirectory` and releases it itself. The backup then does not // `lockBackupDirectory`, and releases it itself, so the backup does not
// take the lock, and fails once this one is lost. `quak backup` takes it // take it. `quak backup` takes it before it opens its library.
// before it opens its library. lockHeld?: boolean;
lock?: BackupLock;
} }
export interface BackupError { export interface BackupError {
@@ -148,9 +129,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;
@@ -160,12 +141,6 @@ export interface BackupResult {
failed: number; failed: number;
// This run's per-file errors, in encounter order. // This run's per-file errors, in encounter order.
errors: BackupError[]; errors: BackupError[];
// The IDs of the albums the library could not decrypt, whose files it
// could not list; the CLI exits non-zero while there are any.
undecryptableAlbums: number[];
// The IDs of the albums whose folder or JSON this run could not write;
// the CLI exits non-zero while there are any.
failedAlbums: number[];
} }
// The slice of the library that backup drives. `Library` implements it; a test // The slice of the library that backup drives. `Library` implements it; a test
@@ -176,9 +151,6 @@ export interface BackupLibrary {
refresh(): Promise<void>; refresh(): Promise<void>;
listCollections(): Collection[]; listCollections(): Collection[];
listFiles(collectionID: number): EnteFile[]; listFiles(collectionID: number): EnteFile[];
// The albums, and files in albums, that the library could not decrypt,
// which `listCollections` and `listFiles` leave out.
undecryptable(): Undecryptable[];
// Get an original's bytes onto disk through the content cache/pools, // Get an original's bytes onto disk through the content cache/pools,
// returning where they landed: `destination` when they were fetched now, // returning where they landed: `destination` when they were fetched now,
// otherwise wherever they already were (the cache, or the library's save // otherwise wherever they already were (the cache, or the library's save
@@ -188,19 +160,7 @@ 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
// library's content pool.
contentConcurrency: number;
// Wait for an ML data fetch to complete, joining one already running or // Wait for an ML data fetch to complete, joining one already running or
// starting one. Rejects with the reason when it fails; resolves at once // starting one. Rejects with the reason when it fails; resolves at once
// when the library cannot fetch ML data. // when the library cannot fetch ML data.
@@ -208,11 +168,6 @@ 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
// not decrypt, gunzip or parse, or undefined when it did or the fetch got
// none. A fetch still running does not change it. A fetch that fails
// leaves it as it was.
mlDataError(fileID: number): unknown;
} }
type FailureClass = "transient" | "permanent" | "unknown"; type FailureClass = "transient" | "permanent" | "unknown";
@@ -241,16 +196,11 @@ const isPresent = (path: string): boolean => {
// Best-effort classification for the ledger. Retryable server/network problems // Best-effort classification for the ledger. Retryable server/network problems
// are transient; refusals and local filesystem/decrypt errors are permanent; // are transient; refusals and local filesystem/decrypt errors are permanent;
// anything else is unknown. The error code and the error as `describeError` // anything else is unknown. Both the error code and message are inspected.
// gives it are inspected, and so is each errno on the error's cause chain,
// where Node's `fetch` puts it. An error named `TimeoutError` is transient
// whatever its message says, as `isRetryable` retries it: each of quak's
// deadlines aborts with one, and the download's says only that it stalled.
const classify = (err: unknown): FailureClass => { const classify = (err: unknown): FailureClass => {
if (hasTransportCode(err)) return "transient";
const e = err as NodeJS.ErrnoException; const e = err as NodeJS.ErrnoException;
if (e?.name === "TimeoutError") return "transient"; const text =
const text = `${e?.code ?? ""} ${describeError(err)}`.toLowerCase(); `${e?.code ?? ""} ${err instanceof Error ? err.message : String(err)}`.toLowerCase();
if ( if (
/timeout|timed out|econnreset|econnrefused|econnaborted|network|socket|eai_again|throttl|temporarily|429|500|502|503|504/.test( /timeout|timed out|econnreset|econnrefused|econnaborted|network|socket|eai_again|throttl|temporarily|429|500|502|503|504/.test(
text, text,
@@ -268,6 +218,9 @@ const classify = (err: unknown): FailureClass => {
return "unknown"; return "unknown";
}; };
const errorMessage = (err: unknown): string =>
err instanceof Error ? err.message : String(err);
// Ensure `linkPath` is a symlink to `target`, rebuilding a missing, wrong, or // Ensure `linkPath` is a symlink to `target`, rebuilding a missing, wrong, or
// non-symlink entry. Throws on failure (a directory in the way, no permission) // non-symlink entry. Throws on failure (a directory in the way, no permission)
// so the caller records it and moves on rather than aborting the run. // so the caller records it and moves on rather than aborting the run.
@@ -287,47 +240,31 @@ const rebuildSymlink = (linkPath: string, target: string): void => {
// The on-disk names for the entries of one directory, in entry order. Each // The on-disk names for the entries of one directory, in entry order. Each
// name is used as is unless another entry would get the same name, ignoring // name is used as is unless another entry would get the same name, ignoring
// case (two names that differ only in case are one entry on a case-insensitive // case (two names that differ only in case are one entry on a case-insensitive
// file system), or it is too long for a file name; then it gets ` (<id>)`, // file system); then every entry sharing it gets ` (<id>)`, before the
// right before the extension when `beforeExtension` is set. To fit, the part // extension when `beforeExtension` is set. A name with an ID added can match
// before the ID is cut short as far as it must be. When the extension alone // another entry's own name (`IMG (6).JPG`), so this repeats until no name is
// leaves no room for the ID, the extension is cut short and the part before // shared. IDs are stable, so the names are too.
// the ID keeps only the bytes that cut leaves over. With `withJSON`, each
// entry also has the JSON file `<name>.json` beside it, as an album does: a
// name then leaves room for `.json`, and one that is another entry's JSON name
// gets its ID added too.
// Two names with different IDs added never match, but one can match another
// entry's own name (`IMG (6).JPG`), so this repeats until no name is shared.
// IDs are stable, so the names are too.
const uniqueNames = ( const uniqueNames = (
entries: { id: number; name: string }[], entries: { id: number; name: string }[],
opts: { beforeExtension?: boolean; withJSON?: boolean }, beforeExtension: boolean,
): string[] => { ): string[] => {
const maxBytes = MAX_NAME_BYTES - (opts.withJSON ? ".json".length : 0);
const withID = (id: number, name: string): string => { const withID = (id: number, name: string): string => {
const tag = ` (${id})`; const ext = beforeExtension ? extname(name) : "";
const fullExt = opts.beforeExtension ? extname(name) : ""; const stem = name.slice(0, name.length - ext.length);
const stem = name.slice(0, name.length - fullExt.length); return `${stem} (${id})${ext}`;
const ext = shortenToBytes(fullExt, maxBytes - Buffer.byteLength(tag));
const room = maxBytes - Buffer.byteLength(tag + ext);
return shortenToBytes(stem, room) + tag + ext;
}; };
const names = entries.map((e) => e.name); const names = entries.map((e) => e.name);
const suffixed = new Set<number>(); const suffixed = new Set<number>();
for (;;) { for (;;) {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
const count = (name: string): void => { for (const name of names) {
const key = name.toLowerCase(); const key = name.toLowerCase();
counts.set(key, (counts.get(key) ?? 0) + 1); counts.set(key, (counts.get(key) ?? 0) + 1);
};
for (const name of names) {
count(name);
if (opts.withJSON) count(`${name}.json`);
} }
let changed = false; let changed = false;
for (const [i, { id, name }] of entries.entries()) { for (const [i, { id, name }] of entries.entries()) {
if (suffixed.has(i)) continue; if (suffixed.has(i)) continue;
const fits = Buffer.byteLength(name) <= maxBytes; if (counts.get(name.toLowerCase()) === 1) continue;
if (fits && counts.get(name.toLowerCase()) === 1) continue;
names[i] = withID(id, name); names[i] = withID(id, name);
suffixed.add(i); suffixed.add(i);
changed = true; changed = true;
@@ -454,6 +391,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.
@@ -490,7 +447,7 @@ const imageMetadataFor = async (
const bytes = await readFile(originalPath); const bytes = await readFile(originalPath);
return { imageMetadata: extractImageMetadata(bytes) ?? {} }; return { imageMetadata: extractImageMetadata(bytes) ?? {} };
} catch (err) { } catch (err) {
return { imageMetadataError: describeError(err) }; return { imageMetadataError: errorMessage(err) };
} }
}; };
@@ -543,14 +500,11 @@ const writeAlbumJSON = (
writeFileSync(path, JSON.stringify(album, null, 2)); writeFileSync(path, JSON.stringify(album, null, 2));
}; };
// The backup itself, which `runBackup` below runs while `lock` is held. Once // The backup itself, which `runBackup` below runs while the lock is held.
// the lock is lost, another backup may be writing to `downloadDirectory`, so
// this one fails before it starts another file.
const runLockedBackup = async ( const runLockedBackup = async (
lib: BackupLibrary, lib: BackupLibrary,
opts: BackupOptions, opts: BackupOptions,
downloadDirectory: string, downloadDirectory: string,
lock: BackupLock,
): Promise<BackupResult> => { ): Promise<BackupResult> => {
const includeOriginals = opts.includeOriginals ?? true; const includeOriginals = opts.includeOriginals ?? true;
const includeThumbnails = opts.includeThumbnails ?? false; const includeThumbnails = opts.includeThumbnails ?? false;
@@ -560,7 +514,6 @@ const runLockedBackup = async (
log("Refreshing library..."); log("Refreshing library...");
await lib.refresh(); await lib.refresh();
if (lock.lost) throw lock.lost;
const collectionsDir = join(downloadDirectory, "collections"); const collectionsDir = join(downloadDirectory, "collections");
const thumbnailsDir = join(downloadDirectory, "thumbnails"); const thumbnailsDir = join(downloadDirectory, "thumbnails");
@@ -621,162 +574,118 @@ const runLockedBackup = async (
let unchecked = 0; let unchecked = 0;
const recordFailure = ( const recordFailure = (
fileID: number, file: EnteFile,
title: string,
collection: string, collection: string,
err: unknown, err: unknown,
): void => { ): void => {
// Count at most one attempt per file per run: a file whose original // Count at most one attempt per file per run: a file whose original
// and thumbnail both fail this run must not double its attempt count // and thumbnail both fail this run must not double its attempt count
// or appear twice in errors. // or appear twice in errors.
if (failedThisRun.has(fileID)) return; if (failedThisRun.has(file.id)) return;
const error = describeError(err); const error = errorMessage(err);
errors.push({ fileID, title, collection, error }); errors.push({
const prior = ledger.get(fileID); fileID: file.id,
ledger.set(fileID, { title: file.metadata.title,
fileID, collection,
title, error,
});
const prior = ledger.get(file.id);
ledger.set(file.id, {
fileID: file.id,
title: file.metadata.title,
classification: classify(err), classification: classify(err),
attempts: (prior?.attempts ?? 0) + 1, attempts: (prior?.attempts ?? 0) + 1,
lastTriedAt: now, lastTriedAt: now,
error, error,
}); });
failedThisRun.add(fileID); failedThisRun.add(file.id);
}; };
// What the library could not decrypt. An album is logged by its ID, as
// its name is not known, so a run limited to some albums logs it too. A
// file in an album in scope is backed up from a copy of it in another
// album that decrypted, even an album out of scope. With no such copy it
// is recorded as failed, with an empty title as its metadata is not known.
const scope = new Set(collections.map((c) => c.id));
const undecryptableAlbums: number[] = [];
for (const record of lib.undecryptable()) {
const error = `could not be decrypted: ${record.error}`;
if (record.fileID === undefined) {
log(`FAILED album ${record.collectionID}: ${error}`);
undecryptableAlbums.push(record.collectionID);
} else if (scope.has(record.collectionID)) {
const copies = memberships.get(record.fileID);
if (copies) {
distinct.set(record.fileID, representative(copies));
} else {
recordFailure(
record.fileID,
"",
collectionName.get(record.collectionID) ?? "",
new Error(error),
);
}
}
}
// Phase 1: get the bytes. Put each pending original at its save path // Phase 1: get the bytes. Put each pending original at its save path
// 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 if (includeOriginals) {
// and left where it is. `storeOriginal` does this for one original, and for (const [fileID, file] of distinct) {
// records its failure. let stored = storedAtSavePath(downloadDirectory, file);
const storeOriginal = async (file: EnteFile): Promise<void> => { let mismatch = false;
const stored = storedAtSavePath(downloadDirectory, file); if (stored !== undefined && verify) {
let mismatch = false; try {
if (stored !== undefined && verify) { if (file.metadata.hash === undefined) {
try { unchecked++;
if (file.metadata.hash === undefined) { } else if (
unchecked++; (await storedHash(stored)) === file.metadata.hash
} else if ((await storedHash(stored)) === file.metadata.hash) { ) {
verified++; verified++;
} else { } else {
log(
`MISMATCH original ${file.metadata.title} (${fileID}): its bytes do not match its content hash`,
);
mismatched++;
mismatch = true;
rmSync(stored.path);
if (stored.videoPath !== undefined) {
rmSync(stored.videoPath);
}
stored = undefined;
}
} catch (err) {
log( log(
`MISMATCH original ${file.metadata.title} (${file.id}): its bytes do not match its content hash`, `FAILED verifying original ${file.metadata.title}: ${errorMessage(err)}`,
); );
mismatched++; recordFailure(
mismatch = true; file,
collectionName.get(file.collectionID) ?? "",
err,
);
continue;
} }
} catch (err) {
log(
`FAILED verifying original ${file.metadata.title}: ${describeError(err)}`,
);
recordFailure(
file.id,
file.metadata.title,
collectionName.get(file.collectionID) ?? "",
err,
);
return;
} }
} if (stored !== undefined) {
if (stored !== undefined && !mismatch) { skipped++;
skipped++; continue;
return; }
} try {
try { log(`Fetching original ${file.metadata.title} (${fileID})...`);
log(`Fetching original ${file.metadata.title} (${file.id})...`);
if (mismatch) {
await replaceOriginal(downloadDirectory, file, (dest) =>
lib.fetchOriginal(file.id, dest),
);
} else {
// A fetched original is written straight to its save path (a // A fetched original is written straight to its save path (a
// live photo beside it); only one that was already cached // live photo beside it); only one that was already cached
// elsewhere is copied. // elsewhere is copied.
await placeOriginal(downloadDirectory, file, (dest) => const placed = await placeOriginal(
lib.original(file.id, dest), downloadDirectory,
file,
(dest) => lib.original(fileID, dest),
);
storedThisRun.add(fileID);
// 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",
);
}
downloaded++;
} catch (err) {
log(
`FAILED original ${file.metadata.title}: ${errorMessage(err)}`,
);
recordFailure(
file,
collectionName.get(file.collectionID) ?? "",
err,
); );
} }
storedThisRun.add(file.id);
downloaded++;
} catch (err) {
log(
`FAILED original ${file.metadata.title}: ${describeError(err)}`,
);
recordFailure(
file.id,
file.metadata.title,
collectionName.get(file.collectionID) ?? "",
err,
);
} }
};
// Up to the content pool's limit of originals are worked on at once,
// their `verify` hashing included, so however large the account is, no
// more than that many are open. Each worker takes the next file from the
// iterator they all share until none is left. Once a worker has thrown,
// as one does when `onProgress` throws, or the lock is lost, no worker
// takes another file. The backup fails only once every worker has
// stopped, so none writes into the backup after it has ended. It checks
// the lock then too, so it does not go on to the thumbnails or the ML
// data fetch without it.
if (includeOriginals) {
const pending = distinct.values();
let workerThrew = false;
const worker = async (): Promise<void> => {
for (const file of pending) {
if (workerThrew || lock.lost) return;
try {
await storeOriginal(file);
} catch (err) {
workerThrew = true;
throw err;
}
}
};
const workers = await Promise.allSettled(
Array.from({ length: lib.contentConcurrency }, worker),
);
for (const w of workers) {
if (w.status === "rejected") throw w.reason;
}
if (lock.lost) throw lock.lost;
} }
if (includeThumbnails) { if (includeThumbnails) {
for (const [fileID, file] of distinct) { for (const [fileID, file] of distinct) {
if (lock.lost) throw lock.lost;
const dest = join(thumbnailsDir, `${fileID}.jpg`); const dest = join(thumbnailsDir, `${fileID}.jpg`);
if (isPresent(dest)) continue; if (isPresent(dest)) continue;
try { try {
@@ -784,8 +693,7 @@ const runLockedBackup = async (
await copyAtomic(path, dest); await copyAtomic(path, dest);
} catch (err) { } catch (err) {
recordFailure( recordFailure(
file.id, file,
file.metadata.title,
collectionName.get(file.collectionID) ?? "", collectionName.get(file.collectionID) ?? "",
err, err,
); );
@@ -796,21 +704,19 @@ const runLockedBackup = async (
// Phase 2: rebuild the derived views from the model. Sidecars first, for // Phase 2: rebuild the derived views from the model. Sidecars first, for
// every present original (this repairs stale ones), each with the file's // every present original (this repairs stale ones), each with the file's
// ML data once an ML data fetch has completed, and its image metadata. // ML data once an ML data fetch has completed, and its image metadata.
// When the fetch got a file's ML data but could not decrypt, gunzip or // When the fetch fails, a file with no cached ML data gets the reason
// parse it, or when the fetch failed, a file with no cached ML data gets // instead and is recorded as failed. The next run fetches its ML data
// the reason instead and is recorded as failed. The next run fetches its // again because none is cached.
// ML data again because none is cached.
if (includeOriginals) { if (includeOriginals) {
let mlDataFailure: unknown; let mlDataError: string | undefined;
try { try {
log("Fetching ML data..."); log("Fetching ML data...");
await lib.fetchMLData(); await lib.fetchMLData();
} catch (err) { } catch (err) {
mlDataFailure = err; mlDataError = errorMessage(err);
log(`FAILED ML data: ${describeError(err)}`); log(`FAILED ML data: ${mlDataError}`);
} }
for (const file of distinct.values()) { for (const file of distinct.values()) {
if (lock.lost) throw lock.lost;
const stored = storedAtSavePath(downloadDirectory, file); const stored = storedAtSavePath(downloadDirectory, file);
if (stored === undefined) continue; if (stored === undefined) continue;
const path = withExtension( const path = withExtension(
@@ -824,19 +730,12 @@ const runLockedBackup = async (
storedThisRun.has(file.id), storedThisRun.has(file.id),
); );
const mlData = await lib.mlData(file.id); const mlData = await lib.mlData(file.id);
const failure = lib.mlDataError(file.id) ?? mlDataFailure; if (mlData === undefined && mlDataError !== undefined) {
if (mlData === undefined && failure !== undefined) { writeSidecar(path, file, { mlDataError }, image);
writeSidecar(
path,
file,
{ mlDataError: describeError(failure) },
image,
);
recordFailure( recordFailure(
file.id, file,
file.metadata.title,
collectionName.get(file.collectionID) ?? "", collectionName.get(file.collectionID) ?? "",
new Error("ML data", { cause: failure }), new Error(`ML data: ${mlDataError}`),
); );
} else { } else {
writeSidecar(path, file, { mlData }, image); writeSidecar(path, file, { mlData }, image);
@@ -855,12 +754,11 @@ const runLockedBackup = async (
id: c.id, id: c.id,
name: sanitizeFileName(c.name, `collection-${c.id}`), name: sanitizeFileName(c.name, `collection-${c.id}`),
})), })),
{ withJSON: true }, false,
); );
const albumDirNames = new Map( const albumDirNames = new Map(
allCollections.map((c, i) => [c.id, dirNames[i]!]), allCollections.map((c, i) => [c.id, dirNames[i]!]),
); );
if (lock.lost) throw lock.lost;
try { try {
removeStaleAlbumDirs( removeStaleAlbumDirs(
collectionsDir, collectionsDir,
@@ -868,72 +766,55 @@ const runLockedBackup = async (
downloadDirectory, downloadDirectory,
); );
} catch (err) { } catch (err) {
log(`FAILED removing old album directories: ${describeError(err)}`); log(`FAILED removing old album directories: ${errorMessage(err)}`);
} }
// An album whose folder or JSON cannot be written, such as one whose
// folder name is taken by a file the user put there, is logged and listed
// in the result, and the run goes on to the next album.
const failedAlbums: number[] = [];
for (const c of collections) { for (const c of collections) {
if (lock.lost) throw lock.lost; const colDirName = albumDirNames.get(c.id)!;
try { const colDir = join(collectionsDir, colDirName);
const colDirName = albumDirNames.get(c.id)!; mkdirSync(colDir, { recursive: true });
const colDir = join(collectionsDir, colDirName);
mkdirSync(colDir, { recursive: true });
// Every album links the one original, saved from the file's entry // Every album links the one original, saved from the file's entry in
// in `distinct`. // `distinct`.
const files = filesByCollection.get(c.id) ?? []; const files = filesByCollection.get(c.id) ?? [];
const links = files.flatMap((f) => const links = files.flatMap((f) =>
linksFor( linksFor(
f, f,
storedAtSavePath(downloadDirectory, distinct.get(f.id)!), storedAtSavePath(downloadDirectory, distinct.get(f.id)!),
), ),
); );
const linkNames = uniqueNames(links, { beforeExtension: true }); const linkNames = uniqueNames(links, true);
try {
removeStaleLinks(colDir, new Set(linkNames), downloadDirectory);
} catch (err) {
log(`FAILED removing old links in ${c.name}: ${errorMessage(err)}`);
}
const metaFiles = files.map((f) => ({
id: f.id,
metadata: f.metadata,
}));
for (const [i, link] of links.entries()) {
if (!includeOriginals || link.target === undefined) continue;
const linkName = linkNames[i]!;
try { try {
removeStaleLinks(colDir, new Set(linkNames), downloadDirectory); rebuildSymlink(
join(colDir, linkName),
relative(colDir, link.target),
);
} catch (err) { } catch (err) {
log( log(
`FAILED removing old links in ${c.name}: ${describeError(err)}`, `FAILED symlink ${c.name}/${linkName}: ${errorMessage(err)}`,
); );
recordFailure(link.file, c.name, err);
} }
const metaFiles = files.map((f) => ({
id: f.id,
metadata: f.metadata,
}));
for (const [i, link] of links.entries()) {
if (!includeOriginals || link.target === undefined) continue;
const linkName = linkNames[i]!;
try {
rebuildSymlink(
join(colDir, linkName),
relative(colDir, link.target),
);
} catch (err) {
log(
`FAILED symlink ${c.name}/${linkName}: ${describeError(err)}`,
);
recordFailure(
link.file.id,
link.file.metadata.title,
c.name,
err,
);
}
}
writeAlbumJSON(
join(collectionsDir, `${colDirName}.json`),
c,
metaFiles,
);
} catch (err) {
log(`FAILED album ${c.name}: ${describeError(err)}`);
failedAlbums.push(c.id);
} }
writeAlbumJSON(
join(collectionsDir, `${colDirName}.json`),
c,
metaFiles,
);
} }
// Reconcile the ledger against what this run actually attempted: an entry // Reconcile the ledger against what this run actually attempted: an entry
@@ -941,9 +822,7 @@ const runLockedBackup = async (
// its failure resolved; a file gone from the library (deleted) or outside // its failure resolved; a file gone from the library (deleted) or outside
// this run's scope is not something this run can resolve, so keeping its // this run's scope is not something this run can resolve, so keeping its
// stale entry would keep the exit code non-zero forever — a single // stale entry would keep the exit code non-zero forever — a single
// since-deleted photo would fail every future scheduled backup. A backup // since-deleted photo would fail every future scheduled backup.
// whose lock was lost leaves the ledger as it is.
if (lock.lost) throw lock.lost;
for (const fileID of [...ledger.keys()]) { for (const fileID of [...ledger.keys()]) {
if (!failedThisRun.has(fileID)) ledger.delete(fileID); if (!failedThisRun.has(fileID)) ledger.delete(fileID);
} }
@@ -958,43 +837,23 @@ const runLockedBackup = async (
unchecked, unchecked,
failed: ledger.size, failed: ledger.size,
errors, errors,
undecryptableAlbums,
failedAlbums,
}; };
}; };
// Only one backup of a directory runs at a time, in this process or another. // Only one backup of a directory runs at a time, in this process or another.
// Creates `downloadDirectory` if it is missing, takes the lock in it and // Creates `downloadDirectory` if it is missing, takes the lock in it and
// returns it; while another backup holds the lock, fails at once with an error // returns the function that releases it; while another backup holds the lock,
// whose `code` is `ELOCKED`. The lock is the directory `backup.lock`, whose // fails at once with an error whose `code` is `ELOCKED`. The lock is the
// modification time proper-lockfile keeps current while it is held. One it has // directory `backup.lock`, whose modification time proper-lockfile keeps
// not touched for 10 seconds was left by a run that could not remove it, and // current while it is held. One it has not touched for 10 seconds was left by
// is taken over. // a run that could not remove it, and is taken over.
//
// proper-lockfile finds the lock lost when `backup.lock` is gone or is another
// backup's, and calls `onCompromised` from a timer, where throwing would end
// the process. The `onCompromised` given here sets the lock's `lost` instead,
// and the backup holding the lock stops before it starts another file (see
// `runLockedBackup`). Releasing a lost lock leaves `backup.lock` alone, since
// it may be another backup's.
export const lockBackupDirectory = async ( export const lockBackupDirectory = async (
downloadDirectory: string, downloadDirectory: string,
): Promise<BackupLock> => { ): Promise<() => Promise<void>> => {
mkdirSync(downloadDirectory, { recursive: true }); mkdirSync(downloadDirectory, { recursive: true });
let release: () => Promise<void>;
const lock: BackupLock = {
release: async () => {
if (lock.lost === undefined) await release();
},
};
try { try {
release = await lockfile.lock(downloadDirectory, { return await lockfile.lock(downloadDirectory, {
lockfilePath: join(downloadDirectory, "backup.lock"), lockfilePath: join(downloadDirectory, "backup.lock"),
onCompromised: (err) => {
lock.lost = new Error(
`lost the lock on ${downloadDirectory}: ${err.message}`,
);
},
}); });
} catch (err) { } catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err; if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err;
@@ -1003,11 +862,10 @@ export const lockBackupDirectory = async (
{ code: "ELOCKED" }, { code: "ELOCKED" },
); );
} }
return lock;
}; };
// Runs the backup holding the lock, which it takes and releases itself unless // Runs the backup holding the lock, which it takes and releases itself unless
// the caller passes the lock it took (`opts.lock`). // the caller already holds it (`opts.lockHeld`).
export const runBackup = async ( export const runBackup = async (
lib: BackupLibrary, lib: BackupLibrary,
opts: BackupOptions, opts: BackupOptions,
@@ -1019,21 +877,13 @@ export const runBackup = async (
"open the library with one)", "open the library with one)",
); );
} }
// As `BoundedPool` does. A limit below 1, or one that is not a number, if (opts.lockHeld) {
// would back up no original and end with no error. return runLockedBackup(lib, opts, downloadDirectory);
const { contentConcurrency } = lib;
if (!Number.isInteger(contentConcurrency) || contentConcurrency < 1) {
throw new RangeError(
`contentConcurrency must be a positive integer, got ${contentConcurrency}`,
);
} }
if (opts.lock) { const release = await lockBackupDirectory(downloadDirectory);
return runLockedBackup(lib, opts, downloadDirectory, opts.lock);
}
const lock = await lockBackupDirectory(downloadDirectory);
try { try {
return await runLockedBackup(lib, opts, downloadDirectory, lock); return await runLockedBackup(lib, opts, downloadDirectory);
} finally { } finally {
await lock.release(); await release();
} }
}; };
+27 -40
View File
@@ -20,7 +20,7 @@ import {
type ClientSnapshot, type ClientSnapshot,
type LoginOptions, type LoginOptions,
} from "./client.js"; } from "./client.js";
import { lockBackupDirectory, type BackupLock } from "./backup.js"; import { lockBackupDirectory } from "./backup.js";
import { init } from "./crypto/index.js"; import { init } from "./crypto/index.js";
import { import {
defaultCacheDirectory, defaultCacheDirectory,
@@ -97,20 +97,6 @@ const requireSession = (ctx: CliContext): Client | undefined => {
return client; return client;
}; };
// `text` as an ID, or undefined after telling the user it is not one. An ID is
// written in decimal digits and is a safe integer; `Number()` alone would read
// `0x10` as 16, `1e3` as 1000 and "" as 0.
const parseID = (
ctx: CliContext,
kind: "file" | "collection",
text: string,
): number | undefined => {
const id = Number(text);
if (/^[0-9]+$/.test(text) && Number.isSafeInteger(id)) return id;
ctx.stderr.write(`Invalid ${kind} ID: ${text}\n`);
return undefined;
};
// A library client that omits `fetchMLData`, so the point commands below do not // A library client that omits `fetchMLData`, so the point commands below do not
// kick the library's background ML backfill: they read metadata, or fetch one // kick the library's background ML backfill: they read metadata, or fetch one
// file's content, and exit. `backup` and `backup-metadata` handle ML on their // file's content, and exit. `backup` and `backup-metadata` handle ML on their
@@ -261,8 +247,11 @@ export const filesCommand = async (
await init(); await init();
const client = requireSession(ctx); const client = requireSession(ctx);
if (!client) return 3; if (!client) return 3;
const collectionID = parseID(ctx, "collection", opts.collection); const collectionID = Number(opts.collection);
if (collectionID === undefined) return 1; if (!Number.isFinite(collectionID)) {
ctx.stderr.write("Invalid collection ID\n");
return 1;
}
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
@@ -300,8 +289,11 @@ export const getCommand = async (
await init(); await init();
const client = requireSession(ctx); const client = requireSession(ctx);
if (!client) return 3; if (!client) return 3;
const fileID = parseID(ctx, "file", fileIDStr); const fileID = Number(fileIDStr);
if (fileID === undefined) return 1; if (!Number.isFinite(fileID)) {
ctx.stderr.write("Invalid file ID\n");
return 1;
}
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
@@ -355,8 +347,11 @@ export const getThumbCommand = async (
await init(); await init();
const client = requireSession(ctx); const client = requireSession(ctx);
if (!client) return 3; if (!client) return 3;
const fileID = parseID(ctx, "file", fileIDStr); const fileID = Number(fileIDStr);
if (fileID === undefined) return 1; if (!Number.isFinite(fileID)) {
ctx.stderr.write("Invalid file ID\n");
return 1;
}
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
@@ -394,11 +389,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 { failedMLBatches } = 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 failedMLBatches > 0 ? 1 : 0;
} finally { } finally {
await lib.close(); await lib.close();
} }
@@ -416,9 +411,9 @@ export const backupCommand = async (
ctx.stderr.write("Starting backup...\n"); ctx.stderr.write("Starting backup...\n");
// The lock is taken before the library opens and starts its refresh, so a // The lock is taken before the library opens and starts its refresh, so a
// run refused while another backup of `dir` runs sends no request. // run refused while another backup of `dir` runs sends no request.
let lock: BackupLock; let release: () => Promise<void>;
try { try {
lock = await lockBackupDirectory(dir); release = await lockBackupDirectory(dir);
} catch (err) { } catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err; if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err;
ctx.stderr.write(`quak: ${(err as Error).message}\n`); ctx.stderr.write(`quak: ${(err as Error).message}\n`);
@@ -440,7 +435,7 @@ export const backupCommand = async (
try { try {
const result = await lib.backup({ const result = await lib.backup({
downloadDirectory: dir, downloadDirectory: dir,
lock, lockHeld: true,
verify: opts.verify, verify: opts.verify,
onProgress: (msg) => { onProgress: (msg) => {
if (!opts.json) ctx.stderr.write(msg + "\n"); if (!opts.json) ctx.stderr.write(msg + "\n");
@@ -470,16 +465,12 @@ export const backupCommand = async (
} }
} }
return result.failed > 0 || return result.failed > 0 ? 1 : 0;
result.undecryptableAlbums.length > 0 ||
result.failedAlbums.length > 0
? 1
: 0;
} finally { } finally {
await lib.close(); await lib.close();
} }
} finally { } finally {
await lock.release(); await release();
} }
}; };
@@ -528,19 +519,15 @@ export const fixMissingThumbnailsCommand = async (
await init(); await init();
const client = requireSession(ctx); const client = requireSession(ctx);
if (!client) return 3; if (!client) return 3;
// One bad --file ID fails the whole command, before anything is fetched.
let fileIDs: number[] = [];
for (const text of opts.file ?? []) {
const fileID = parseID(ctx, "file", text);
if (fileID === undefined) return 1;
fileIDs.push(fileID);
}
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
// Refresh first so files added since the cache was written are found; // Refresh first so files added since the cache was written are found;
// a failed refresh throws. // a failed refresh throws.
await lib.fresh(); await lib.fresh();
if (fileIDs.length === 0) { let fileIDs: number[];
if (opts.file && opts.file.length > 0) {
fileIDs = opts.file.map(Number).filter(Number.isFinite);
} else {
ctx.stderr.write("Scanning for missing thumbnails...\n"); ctx.stderr.write("Scanning for missing thumbnails...\n");
const missing = await listMissingThumbnails(lib, client, (msg) => { const missing = await listMissingThumbnails(lib, client, (msg) => {
if (!opts.json) ctx.stderr.write(msg + "\n"); if (!opts.json) ctx.stderr.write(msg + "\n");
+6 -7
View File
@@ -2,17 +2,14 @@
import type { Writable } from "node:stream"; import type { Writable } from "node:stream";
import { ApiError } from "./api/client.js"; import { ApiError } from "./api/client.js";
import { describeError } from "./errors.js";
// Run a command and exit with its code once stdout/stderr have drained. // Run a command and exit with its code once stdout/stderr have drained.
// Exiting before the drain can truncate piped output, and the library can keep // Exiting before the drain can truncate piped output, and the library can keep
// the event loop alive after a command returns, so a plain return could hang. // the event loop alive after a command returns, so a plain return could hang.
// An error the command throws is printed as one `quak: MESSAGE` line, without // An error the command throws is printed as one `quak: MESSAGE` line, without
// the stack trace, and exits 1. MESSAGE is the error as `describeError` gives // the stack trace, and exits 1. A 401 from the server means it no longer
// it, so it carries the reason Node's `fetch` puts on `cause`. A 401 from the // accepts the saved session: that prints one line saying to log in again and
// server means it no longer accepts the saved session: that prints one line // exits 3, as a missing or corrupt session file does.
// saying to log in again and exits 3, as a missing or corrupt session file
// does.
export const run = async ( export const run = async (
command: Promise<number>, command: Promise<number>,
stdout: Writable, stdout: Writable,
@@ -29,7 +26,9 @@ export const run = async (
); );
code = 3; code = 3;
} else { } else {
stderr.write(`quak: ${describeError(err)}\n`); stderr.write(
`quak: ${err instanceof Error ? err.message : String(err)}\n`,
);
code = 1; code = 1;
} }
} }
+30 -61
View File
@@ -44,28 +44,20 @@ export interface ClientSnapshot {
// The result of a resumable enumeration. Live decrypted records and deleted // The result of a resumable enumeration. Live decrypted records and deleted
// ids are kept apart on purpose: a tombstone carries no key or metadata to // ids are kept apart on purpose: a tombstone carries no key or metadata to
// decrypt, so it is a bare id rather than a hollowed-out record. A live row // decrypt, so it is a bare id rather than a hollowed-out record. `cursor` is
// that cannot be decrypted, or whose metadata cannot be parsed, is in // the max `updationTime` seen, to pass back into the next call.
// `undecryptable` with the reason instead, so one bad row does not fail the
// call; absent means there was none. `cursor` is the max `updationTime` seen,
// those rows included, to pass back into the next call.
export interface CollectionsPage { export interface CollectionsPage {
collections: Collection[]; collections: Collection[];
deleted: number[]; deleted: number[];
undecryptable?: { id: number; error: string }[];
cursor: number; cursor: number;
} }
export interface FilesPage { export interface FilesPage {
files: EnteFile[]; files: EnteFile[];
deleted: number[]; deleted: number[];
undecryptable?: { id: number; error: string }[];
cursor: number; cursor: number;
} }
const errorMessage = (err: unknown): string =>
err instanceof Error ? err.message : String(err);
export class Client { export class Client {
private readonly api: ApiClient; private readonly api: ApiClient;
private readonly email: string; private readonly email: string;
@@ -245,12 +237,11 @@ export class Client {
} }
// Enumerate collections changed since `sinceTime`. Live collections are // Enumerate collections changed since `sinceTime`. Live collections are
// decrypted; tombstoned ones (isDeleted) are surfaced as bare ids, and // decrypted; tombstoned ones (isDeleted) are surfaced as bare ids. The
// ones that do not decrypt as ids with the reason. The returned cursor is // returned cursor is the max `updationTime` seen — including tombstones, so
// the max `updationTime` seen — including tombstones, so the next sync // the next sync resumes past them — falling back to `sinceTime` when the
// resumes past them — falling back to `sinceTime` when the response is // response is empty. `/collections/v2` returns the whole changed set in one
// empty. `/collections/v2` returns the whole changed set in one response, // response, so there is no pagination here.
// so there is no pagination here.
async collectionsSince(args: { async collectionsSince(args: {
sinceTime: number; sinceTime: number;
}): Promise<CollectionsPage> { }): Promise<CollectionsPage> {
@@ -263,40 +254,31 @@ export class Client {
const collections: Collection[] = []; const collections: Collection[] = [];
const deleted: number[] = []; const deleted: number[] = [];
const undecryptable: { id: number; error: string }[] = [];
let cursor = args.sinceTime; let cursor = args.sinceTime;
for (const raw of raws) { for (const raw of raws) {
if (raw.isDeleted) { if (raw.isDeleted) {
deleted.push(raw.id); deleted.push(raw.id);
} else { } else {
try { collections.push(
collections.push( decryptCollection(
decryptCollection( raw,
raw, {
{ masterKey: this.masterKey,
masterKey: this.masterKey, publicKey: this.publicKey,
publicKey: this.publicKey, secretKey: this.secretKey,
secretKey: this.secretKey, },
}, this.userID,
this.userID, ),
), );
);
} catch (err) {
undecryptable.push({
id: raw.id,
error: errorMessage(err),
});
}
} }
if (raw.updationTime > cursor) cursor = raw.updationTime; if (raw.updationTime > cursor) cursor = raw.updationTime;
} }
return { collections, deleted, undecryptable, cursor }; return { collections, deleted, cursor };
} }
// Enumerate a collection's files changed since `sinceTime`, paginating the // Enumerate a collection's files changed since `sinceTime`, paginating the
// diff from that cursor. Live rows are decrypted; tombstoned ones are // diff from that cursor. Live rows are decrypted; tombstoned ones are
// surfaced as bare ids, and ones that do not decrypt or parse as ids with // surfaced as bare ids. Returns the final cursor to resume from.
// the reason. Returns the final cursor to resume from.
async filesSince(args: { async filesSince(args: {
collectionID: number; collectionID: number;
collectionKey: Uint8Array; collectionKey: Uint8Array;
@@ -306,7 +288,6 @@ export class Client {
const { collectionID, collectionKey } = args; const { collectionID, collectionKey } = args;
const files: EnteFile[] = []; const files: EnteFile[] = [];
const deleted: number[] = []; const deleted: number[] = [];
const undecryptable: { id: number; error: string }[] = [];
let cursor = args.sinceTime; let cursor = args.sinceTime;
for (;;) { for (;;) {
const { diff, hasMore } = await this.api.getJSON<{ const { diff, hasMore } = await this.api.getJSON<{
@@ -319,14 +300,7 @@ export class Client {
if (raw.isDeleted) { if (raw.isDeleted) {
deleted.push(raw.id); deleted.push(raw.id);
} else { } else {
try { files.push(decryptFile(raw, collectionKey));
files.push(decryptFile(raw, collectionKey));
} catch (err) {
undecryptable.push({
id: raw.id,
error: errorMessage(err),
});
}
} }
if (raw.updationTime > pageMax) pageMax = raw.updationTime; if (raw.updationTime > pageMax) pageMax = raw.updationTime;
} }
@@ -347,19 +321,18 @@ export class Client {
} }
cursor = pageMax; cursor = pageMax;
} }
return { files, deleted, undecryptable, cursor }; return { files, deleted, cursor };
} }
// Whole-account listing: every live collection that decrypts, deletions // Whole-account listing: every live collection, deletions hidden. A thin
// hidden. A thin wrapper over `collectionsSince` from the beginning of // wrapper over `collectionsSince` from the beginning of time.
// time.
async listCollections(): Promise<Collection[]> { async listCollections(): Promise<Collection[]> {
const { collections } = await this.collectionsSince({ sinceTime: 0 }); const { collections } = await this.collectionsSince({ sinceTime: 0 });
return collections; return collections;
} }
// Every live file in a collection that decrypts, deletions hidden. A thin // Every live file in a collection, deletions hidden. A thin wrapper over
// wrapper over `filesSince` from the beginning of time. // `filesSince` from the beginning of time.
async listFiles( async listFiles(
collectionID: number, collectionID: number,
collectionKey: Uint8Array, collectionKey: Uint8Array,
@@ -373,17 +346,13 @@ export class Client {
} }
// Fetch machine-learning data (face detections + CLIP embeddings) for up // Fetch machine-learning data (face detections + CLIP embeddings) for up
// to a batch of files, each decrypted with its own key, and for each file // to a batch of files, each decrypted with its own key. One request; the
// whose ML data did not decrypt, gunzip or parse, why not. One request; // library batches at `MLDATA_BATCH_SIZE` and schedules each batch through
// the library batches at `MLDATA_BATCH_SIZE` and schedules each batch // its metadata request pool.
// through its metadata request pool.
async fetchMLData(args: { async fetchMLData(args: {
fileIDs: number[]; fileIDs: number[];
fileKeys: Map<number, Uint8Array>; fileKeys: Map<number, Uint8Array>;
}): Promise<{ }): Promise<Map<number, MLData>> {
payloads: Map<number, MLData>;
errors: Map<number, unknown>;
}> {
this.assertLoggedIn(); this.assertLoggedIn();
return fetchMLDataBatch(this.api, args.fileIDs, args.fileKeys); return fetchMLDataBatch(this.api, args.fileIDs, args.fileKeys);
} }
+9 -15
View File
@@ -135,17 +135,15 @@ const streamDecrypt = async (
// cannot tell a partial chunk from a corrupt one, so this // cannot tell a partial chunk from a corrupt one, so this
// is reported as the truncation it almost always is, with // is reported as the truncation it almost always is, with
// the authentication failure kept as the error's cause. // the authentication failure kept as the error's cause.
// The message leaves that failure to the cause, which // Only the pull is guarded: a sink failure on a chunk
// `describeError` appends to what `quak backup` prints and // that did authenticate is a disk error, not a
// records. Only the pull is guarded: a sink failure on a
// chunk that did authenticate is a disk error, not a
// truncation. // truncation.
let pulled; let pulled;
try { try {
pulled = pullStreamChunk(state, buffer); pulled = pullStreamChunk(state, buffer);
} catch (err) { } catch (err) {
throw new TruncatedStreamError( throw new TruncatedStreamError(
`download: stream truncated: response body ended with ${buffer.length} trailing bytes (transfer stopped mid-chunk, or the data is corrupt)`, `download: stream truncated: response body ended with ${buffer.length} trailing bytes that did not authenticate as a final chunk (transfer stopped mid-chunk, or the data is corrupt)`,
{ cause: err }, { cause: err },
); );
} }
@@ -199,13 +197,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 +210,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 });
} }
+3 -71
View File
@@ -1,13 +1,10 @@
// Error types that more than one layer of quak needs to recognise, and how an // Error types that more than one layer of quak needs to recognise.
// error is described in what quak prints and in `failures.json`.
// //
// The types live here rather than beside the code that throws them so that the // They live here rather than beside the code that throws them so that the
// retry classifier can identify them without importing the HTTP client or the // retry classifier can identify them without importing the HTTP client or the
// download layer — both of which import the classifier. `ApiError` is // download layer — both of which import the classifier. `ApiError` is
// re-exported from `src/api/client.ts`, which is where callers have always // re-exported from `src/api/client.ts`, which is where callers have always
// imported it from and where it still belongs conceptually. The walk along an // imported it from and where it still belongs conceptually.
// error's `cause` chain is here too, because the retry classifier reads the
// chain for errnos and `describeError` for messages.
export class ApiError extends Error { export class ApiError extends Error {
readonly status: number; readonly status: number;
@@ -46,68 +43,3 @@ export class TruncatedStreamError extends Error {
this.name = "TruncatedStreamError"; this.name = "TruncatedStreamError";
} }
} }
// `cause` is an arbitrary user-settable property and nothing prevents it from
// forming a cycle, so the walk is bounded. Hanging the process would be worse
// than misclassifying an error or describing only part of it.
const MAX_CAUSE_DEPTH = 8;
// The error and each error on its `cause` chain, top first, at most
// `MAX_CAUSE_DEPTH` of them. `complete` is false when the walk stopped at the
// depth limit with more of the chain still below it.
export const causeChain = (
err: unknown,
): { chain: object[]; complete: boolean } => {
const chain: object[] = [];
let current: unknown = err;
for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) {
if (current === null || typeof current !== "object") {
return { chain, complete: true };
}
chain.push(current);
const { cause } = current as { cause?: unknown };
if (cause === current) return { chain, complete: true };
current = cause;
}
return { chain, complete: current === null || typeof current !== "object" };
};
// An error as quak prints it and records it in `failures.json`: its message,
// then the message of each error on its `cause` chain, joined with ": ". Node's
// `fetch` rejects with just "fetch failed" and puts the reason on `cause`, so
// this gives "fetch failed: connect ECONNREFUSED 1.2.3.4:443". When the host
// has several addresses and every one fails, the cause is an `AggregateError`
// with an empty message and one error per address in `errors`, so an
// `AggregateError` is given as the messages of its errors, joined with ", ".
// A cause that is not an object, such as a thrown string, is given as
// `String(cause)`. Each message is trimmed of the white space around it, as
// OpenSSL ends its own with a newline. A message the text already holds is
// left out.
export const describeError = (err: unknown): string => {
if (!(err instanceof Error)) return String(err);
const { chain } = causeChain(err);
const messages = chain
.slice(1)
.map((cause) =>
cause instanceof AggregateError
? cause.errors
.map((e: unknown) =>
(e instanceof Error ? e.message : String(e)).trim(),
)
.join(", ")
: (cause as { message?: unknown }).message,
);
// `causeChain` stops at a cause that is not an object, so that one is read
// from the last error on the chain.
const last = chain[chain.length - 1] as { cause?: unknown };
if (last.cause !== undefined && typeof last.cause !== "object") {
messages.push(String(last.cause));
}
let text = err.message.trim();
for (const message of messages) {
if (typeof message !== "string") continue;
const trimmed = message.trim();
if (!text.includes(trimmed)) text += `: ${trimmed}`;
}
return text;
};
+1 -19
View File
@@ -4,8 +4,7 @@
// hands us, and quak does not trust the server. Any name taken from them and // hands us, and quak does not trust the server. Any name taken from them and
// used on disk goes through here, so it can only ever name one file inside the // used on disk goes through here, so it can only ever name one file inside the
// directory the caller chose: never a path, never `..`, never hidden, never a // directory the caller chose: never a path, never `..`, never hidden, never a
// Windows device name. Such a name can still be too long for a file name, and // Windows device name.
// `shortenToBytes` cuts one short.
// //
// A path the user typed (`--out`, `outPath`) is not passed through here: the // A path the user typed (`--out`, `outPath`) is not passed through here: the
// caller is trusted, the server is not. // caller is trusted, the server is not.
@@ -40,20 +39,3 @@ export const safeExtension = (title: string): string => {
// `name` with its extension, if it has one, replaced by `ext` (".mov"). // `name` with its extension, if it has one, replaced by `ext` (".mov").
export const withExtension = (name: string, ext: string): string => export const withExtension = (name: string, ext: string): string =>
name.slice(0, name.length - extname(name).length) + ext; name.slice(0, name.length - extname(name).length) + ext;
// The longest file name, in bytes, that ext4 and most other file systems allow.
export const MAX_NAME_BYTES = 255;
// `name` cut short to at most `maxBytes` bytes of UTF-8, the encoding a file
// name is written to disk in, between two characters, never inside one. A name
// that fits comes back unchanged.
export const shortenToBytes = (name: string, maxBytes: number): string => {
let bytes = 0;
let end = 0;
for (const character of name) {
bytes += Buffer.byteLength(character);
if (bytes > maxBytes) break;
end += character.length;
}
return name.slice(0, end);
};
-1
View File
@@ -69,7 +69,6 @@ export {
type EnsureEvent, type EnsureEvent,
lockBackupDirectory, lockBackupDirectory,
runBackup, runBackup,
type BackupLock,
type BackupOptions, type BackupOptions,
type BackupResult, type BackupResult,
type BackupError, type BackupError,
+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);
} }
+14 -88
View File
@@ -89,7 +89,6 @@ export {
} from "./content.js"; } from "./content.js";
export { type MLDataAPI, type SimilarResult } from "./mlsearch.js"; export { type MLDataAPI, type SimilarResult } from "./mlsearch.js";
import type { CollectionsPage, FilesPage } from "../client.js"; import type { CollectionsPage, FilesPage } from "../client.js";
import { describeError } from "../errors.js";
import { MLDATA_BATCH_SIZE, type MLData } from "../mldata-fetch.js"; import { MLDATA_BATCH_SIZE, type MLData } from "../mldata-fetch.js";
import type { Collection, EnteFile } from "../model/types.js"; import type { Collection, EnteFile } from "../model/types.js";
import { runBackup, type BackupOptions, type BackupResult } from "../backup.js"; import { runBackup, type BackupOptions, type BackupResult } from "../backup.js";
@@ -97,7 +96,6 @@ import { runBackup, type BackupOptions, type BackupResult } from "../backup.js";
export { export {
lockBackupDirectory, lockBackupDirectory,
runBackup, runBackup,
type BackupLock,
type BackupOptions, type BackupOptions,
type BackupResult, type BackupResult,
type BackupError, type BackupError,
@@ -139,16 +137,12 @@ export interface LibraryClient {
sinceTime: number; sinceTime: number;
}): Promise<FilesPage>; }): Promise<FilesPage>;
// Fetch ML data (face detections + CLIP embeddings) for up to a batch of // Fetch ML data (face detections + CLIP embeddings) for up to a batch of
// files, and for each file whose ML data did not decrypt, gunzip or parse, // files. Optional: a client without it simply disables ML fetching, leaving
// why not. Optional: a client without it simply disables ML fetching, // the metadata refresh untouched.
// leaving the metadata refresh untouched.
fetchMLData?(args: { fetchMLData?(args: {
fileIDs: number[]; fileIDs: number[];
fileKeys: Map<number, Uint8Array>; fileKeys: Map<number, Uint8Array>;
}): Promise<{ }): Promise<Map<number, MLData>>;
payloads: Map<number, MLData>;
errors: Map<number, unknown>;
}>;
// The byte source for the on-disk content cache. Optional so a mock client // The byte source for the on-disk content cache. Optional so a mock client
// that only serves metadata still satisfies the interface; when absent (and // that only serves metadata still satisfies the interface; when absent (and
// no explicit `contentSource` is passed to `open`) the content cache is // no explicit `contentSource` is passed to `open`) the content cache is
@@ -159,11 +153,9 @@ export interface LibraryClient {
// A progress event for one unit of background work. A metadata "refresh" or an // A progress event for one unit of background work. A metadata "refresh" or an
// ML "fetchMLData" pass each fire "started" before their network work and then // ML "fetchMLData" pass each fire "started" before their network work and then
// exactly one of "done" or "failed"; "failed" carries the error message and // exactly one of "done" or "failed"; "failed" carries the error message and
// an ML "done" reports how many payloads it stored and, in `mlDataErrors`, // an ML "done" reports how many payloads it stored. The precache fills
// each file whose ML data did not decrypt, gunzip or parse, with the reason. // ("precacheThumbnails"/"precacheOriginals", #48) fire "started"/"done" around
// The precache fills ("precacheThumbnails"/"precacheOriginals", #48) fire // each sweep that has work, "done" reporting the count newly cached.
// "started"/"done" around each sweep that has work, "done" reporting the count
// newly cached.
export interface RefreshEvent { export interface RefreshEvent {
operation: operation:
| "refresh" | "refresh"
@@ -173,7 +165,6 @@ export interface RefreshEvent {
status: "started" | "done" | "failed"; status: "started" | "done" | "failed";
error?: string; error?: string;
fetched?: number; fetched?: number;
mlDataErrors?: { fileID: number; error: string }[];
} }
export type RefreshProgressCallback = (event: RefreshEvent) => void; export type RefreshProgressCallback = (event: RefreshEvent) => void;
@@ -233,11 +224,6 @@ 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
// could not decrypt, gunzip or parse, with the reason. A pass still
// running leaves it as it is. A pass that fails leaves it as it was. Such
// ML data is not cached, so the next pass asks for it again.
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.
mlStored?: number; mlStored?: number;
@@ -305,12 +291,6 @@ 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
// but could not decrypt, gunzip or parse, why not, by fileID. A pass
// gathers its own and puts them here when it ends without failing, so
// `status()` and `backup()` never see the list of a pass that is still
// 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
@@ -561,7 +541,6 @@ export class Library {
lastError: this.lastError, lastError: this.lastError,
lastMLFetchAt: this.lastMLFetchAt, lastMLFetchAt: this.lastMLFetchAt,
lastMLError: this.lastMLError, lastMLError: this.lastMLError,
mlDataErrors: this.describeMLDataErrors(),
mlStored: ml?.stored, mlStored: ml?.stored,
mlIndexed: ml?.indexed, mlIndexed: ml?.indexed,
originalsUsedBytes: originals?.usedBytes, originalsUsedBytes: originals?.usedBytes,
@@ -591,10 +570,9 @@ export class Library {
// Back up every in-scope file to `opts.downloadDirectory`, or else the // Back up every in-scope file to `opts.downloadDirectory`, or else the
// library's, each original at its save path, with a durable failure // library's, each original at its save path, with a durable failure
// ledger (issue #51). Takes the lock in that directory first, unless the // ledger (issue #51). Takes the lock in that directory first, unless
// caller passes the lock it took as `opts.lock`, failing at once while // `opts.lockHeld` says the caller holds it, failing at once while another
// another backup of it runs, and before it starts another file once the // backup of it runs (see `runBackup`). Waits for a
// lock is lost (see `lockBackupDirectory`). Waits for a
// completed refresh, as `fresh()` does, joining one already running, and // completed refresh, as `fresh()` does, joining one already running, and
// rejects before touching any file but the lock when it fails. Then puts // rejects before touching any file but the lock when it fails. Then puts
// pending originals at their save paths as `Photo.download()` does (and // pending originals at their save paths as `Photo.download()` does (and
@@ -623,16 +601,11 @@ export class Library {
refresh: () => this.refreshNow(), refresh: () => this.refreshNow(),
listCollections: () => this.store.listCollections(), listCollections: () => this.store.listCollections(),
listFiles: (id) => this.store.listFiles(id), listFiles: (id) => this.store.listFiles(id),
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,
fetchMLData: () => this.fetchMLDataNow(), fetchMLData: () => this.fetchMLDataNow(),
mlData: (fileID) => this.mldata.forFile({ fileID }), mlData: (fileID) => this.mldata.forFile({ fileID }),
mlDataError: (fileID) => this.mlDataErrors.get(fileID),
}, },
{ ...opts, downloadDirectory }, { ...opts, downloadDirectory },
); );
@@ -730,10 +703,7 @@ export class Library {
// Fetch every change since the stored cursor, then commit. All network // Fetch every change since the stored cursor, then commit. All network
// reads happen before any store mutation, so a fetch that throws leaves the // reads happen before any store mutation, so a fetch that throws leaves the
// store untouched and the previous snapshot intact. A collection or file // store untouched and the previous snapshot intact.
// that does not decrypt is left out instead, as if deleted, with the reason
// kept in the store: the cursor moves past it, so no later refresh sees it
// again until the server's copy changes.
private async refreshOnce(): Promise<void> { private async refreshOnce(): Promise<void> {
const page = await this.client.collectionsSince({ const page = await this.client.collectionsSince({
sinceTime: this.store.collectionsSinceTime, sinceTime: this.store.collectionsSinceTime,
@@ -770,17 +740,9 @@ export class Library {
this.store.deleteCollection(id); this.store.deleteCollection(id);
changed = true; changed = true;
} }
if (this.store.deleteUndecryptable(id)) changed = true;
}
// A collection left out takes its files with it.
for (const { id, error } of page.undecryptable ?? []) {
this.store.deleteCollection(id);
this.store.putUndecryptable({ collectionID: id, error });
changed = true;
} }
for (const collection of page.collections) { for (const collection of page.collections) {
this.store.putCollection(collection); this.store.putCollection(collection);
this.store.deleteUndecryptable(collection.id);
changed = true; changed = true;
} }
@@ -790,22 +752,9 @@ export class Library {
this.store.deleteFile(collectionID, id); this.store.deleteFile(collectionID, id);
changed = true; changed = true;
} }
if (this.store.deleteUndecryptable(collectionID, id)) {
changed = true;
}
}
for (const { id, error } of filePage.undecryptable ?? []) {
this.store.deleteFile(collectionID, id);
this.store.putUndecryptable({
collectionID,
fileID: id,
error,
});
changed = true;
} }
for (const f of filePage.files) { for (const f of filePage.files) {
this.store.putFile(f); this.store.putFile(f);
this.store.deleteUndecryptable(collectionID, f.id);
changed = true; changed = true;
} }
} }
@@ -859,11 +808,8 @@ 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. A
// file whose ML data does not decrypt, gunzip or parse, it gathers the // failure is reported through `status()` and `onProgress`, then thrown.
// reason instead, and puts the reasons it gathered in `mlDataErrors` when
// it ends without failing. A failure leaves `mlDataErrors` as it was and
// 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 +819,7 @@ 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 if (needed.length === 0) return;
// failing. A file whose ML data did not decode in the pass before is
// still among `needed`, so this pass gives its reason anew.
const mlDataErrors = new Map<number, unknown>();
if (needed.length === 0) {
this.mlDataErrors = mlDataErrors;
return;
}
this.emit({ operation: "fetchMLData", status: "started" }); this.emit({ operation: "fetchMLData", status: "started" });
try { try {
@@ -895,25 +834,20 @@ export class Library {
for (let i = 0; i < needed.length; i += MLDATA_BATCH_SIZE) { for (let i = 0; i < needed.length; i += MLDATA_BATCH_SIZE) {
if (this.closed) break; if (this.closed) break;
const batch = needed.slice(i, i + MLDATA_BATCH_SIZE); const batch = needed.slice(i, i + MLDATA_BATCH_SIZE);
const { payloads, errors } = await this.pools.metadata.run( const payloads = await this.pools.metadata.run(
() => fetchMLData({ fileIDs: batch, fileKeys }), () => fetchMLData({ fileIDs: batch, fileKeys }),
{ priority: "background" }, { priority: "background" },
); );
for (const [id, error] of errors) {
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({
operation: "fetchMLData", operation: "fetchMLData",
status: "done", status: "done",
fetched: stored, fetched: stored,
mlDataErrors: this.describeMLDataErrors(),
}); });
} catch (err) { } catch (err) {
const error = err instanceof Error ? err.message : String(err); const error = err instanceof Error ? err.message : String(err);
@@ -923,14 +857,6 @@ export class Library {
} }
} }
// `mlDataErrors` as `status()` and the ML "done" event give it.
private describeMLDataErrors(): { fileID: number; error: string }[] {
return [...this.mlDataErrors].map(([fileID, err]) => ({
fileID,
error: describeError(err),
}));
}
// The distinct files the store holds, one entry per fileID (a file in // The distinct files the store holds, one entry per fileID (a file in
// several collections shares its ML data), each carrying the key and the // several collections shares its ML data), each carrying the key and the
// newest `updationTime` seen across its memberships. // newest `updationTime` seen across its memberships.
+5 -50
View File
@@ -2,11 +2,10 @@
// //
// The store keeps one `metadata.json` file holding the account's server // The store keeps one `metadata.json` file holding the account's server
// state: the user id, a schema version, the cursor for the incremental // state: the user id, a schema version, the cursor for the incremental
// collections listing, the decrypted collection and file records, and the // collections listing, and the decrypted collection and file records. The
// collections and files the refresh could not decrypt. The whole file is read // whole file is read into RAM on load and rewritten as a whole on save; there
// into RAM on load and rewritten as a whole on save; there is no partial // is no partial update and no lock file. A separate refresh unit populates the
// update and no lock file. A separate refresh unit populates the store from // store from the server — this module only stores what it is given.
// the server — this module only stores what it is given.
// //
// The file is a cache, so it is never trusted to exist or to be intact: a // The file is a cache, so it is never trusted to exist or to be intact: a
// missing or unreadable file loads as an empty store rather than an error, and // missing or unreadable file loads as an empty store rather than an error, and
@@ -34,22 +33,12 @@ const FILE_MODE = 0o600;
type StoredCollection = Omit<Collection, "key"> & { key: string }; type StoredCollection = Omit<Collection, "key"> & { key: string };
type StoredFile = Omit<EnteFile, "key"> & { key: string }; type StoredFile = Omit<EnteFile, "key"> & { key: string };
// A collection, or with `fileID` a file in it, that the last refresh to fetch
// it could not decrypt or parse, and why. It is kept until a later refresh
// fetches it again, because a refresh fetches only what changed.
export interface Undecryptable {
collectionID: number;
fileID?: number;
error: string;
}
interface StoredMetadata { interface StoredMetadata {
schemaVersion: number; schemaVersion: number;
userID: number; userID: number;
collectionsSinceTime: Microseconds; collectionsSinceTime: Microseconds;
collections: StoredCollection[]; collections: StoredCollection[];
files: StoredFile[]; files: StoredFile[];
undecryptable: Undecryptable[];
} }
const encodeKey = (key: Uint8Array): string => const encodeKey = (key: Uint8Array): string =>
@@ -78,7 +67,6 @@ export class MetadataStore {
private readonly collections = new Map<number, Collection>(); private readonly collections = new Map<number, Collection>();
private readonly files = new Map<string, EnteFile>(); private readonly files = new Map<string, EnteFile>();
private undecryptable: Undecryptable[] = [];
private constructor(path: string) { private constructor(path: string) {
this.path = path; this.path = path;
@@ -119,12 +107,6 @@ export class MetadataStore {
}; };
store.files.set(fileKey(file.collectionID, file.id), file); store.files.set(fileKey(file.collectionID, file.id), file);
} }
for (const record of parsed.undecryptable) {
if (typeof record.collectionID !== "number") {
throw new Error("damaged record of what did not decrypt");
}
store.undecryptable.push(record);
}
} catch { } catch {
// Any corruption discards the partial result: a half-read cache is // Any corruption discards the partial result: a half-read cache is
// worse than an empty one, since the refresh unit will rebuild it. // worse than an empty one, since the refresh unit will rebuild it.
@@ -152,7 +134,6 @@ export class MetadataStore {
...f, ...f,
key: encodeKey(f.key), key: encodeKey(f.key),
})), })),
undecryptable: this.undecryptable,
}; };
const dir = dirname(this.path); const dir = dirname(this.path);
@@ -184,8 +165,7 @@ export class MetadataStore {
this.collections.set(collection.id, collection); this.collections.set(collection.id, collection);
} }
// Removing a collection also drops its file memberships, and any record // Removing a collection also drops its file memberships: a file record is
// that it or one of its files could not be decrypted: a file record is
// only meaningful as part of a collection the cache still knows about. // only meaningful as part of a collection the cache still knows about.
deleteCollection(id: number): void { deleteCollection(id: number): void {
this.collections.delete(id); this.collections.delete(id);
@@ -194,9 +174,6 @@ export class MetadataStore {
this.files.delete(key); this.files.delete(key);
} }
} }
this.undecryptable = this.undecryptable.filter(
(record) => record.collectionID !== id,
);
} }
getFile(collectionID: number, fileID: number): EnteFile | undefined { getFile(collectionID: number, fileID: number): EnteFile | undefined {
@@ -228,26 +205,4 @@ export class MetadataStore {
deleteFile(collectionID: number, fileID: number): void { deleteFile(collectionID: number, fileID: number): void {
this.files.delete(fileKey(collectionID, fileID)); this.files.delete(fileKey(collectionID, fileID));
} }
listUndecryptable(): Undecryptable[] {
return [...this.undecryptable];
}
// Replaces any earlier record of the same collection or file.
putUndecryptable(record: Undecryptable): void {
this.deleteUndecryptable(record.collectionID, record.fileID);
this.undecryptable.push(record);
}
// Forgets the record of the collection, or with `fileID` of that file in
// it. Returns whether there was one.
deleteUndecryptable(collectionID: number, fileID?: number): boolean {
const before = this.undecryptable.length;
this.undecryptable = this.undecryptable.filter(
(record) =>
record.collectionID !== collectionID ||
record.fileID !== fileID,
);
return this.undecryptable.length < before;
}
} }
+8 -14
View File
@@ -2,7 +2,6 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import * as jpeg from "jpeg-js"; import * as jpeg from "jpeg-js";
import type { Client } from "./client.js"; import type { Client } from "./client.js";
import { describeError } from "./errors.js";
import { readExifTags } from "./exif.js"; import { readExifTags } from "./exif.js";
import type { Library, Photo } from "./library/index.js"; import type { Library, Photo } from "./library/index.js";
import { sanitizeFileName } from "./filename.js"; import { sanitizeFileName } from "./filename.js";
@@ -92,15 +91,14 @@ const extractExif = async (
// of plain JSON: account, per-collection, and per-file records including the // of plain JSON: account, per-collection, and per-file records including the
// private and public magic metadata and (by default) the ML data. Collections // private and public magic metadata and (by default) the ML data. Collections
// and files are enumerated from the library's cache, which the caller refreshes // and files are enumerated from the library's cache, which the caller refreshes
// first. Returns how many files have no ML data because their ML data request // first. Returns how many ML data requests failed; their files are still
// failed or their ML data did not decrypt, gunzip or parse; they are still
// written, with `mlDataError` in place of `mlData`. // written, with `mlDataError` in place of `mlData`.
export const runMetadataBackup = async ( export const runMetadataBackup = async (
lib: Library, lib: Library,
client: Client, client: Client,
outDir: string, outDir: string,
opts?: MetadataBackupOptions, opts?: MetadataBackupOptions,
): Promise<{ filesWithMLDataError: number }> => { ): Promise<{ failedMLBatches: number }> => {
const log = opts?.onProgress ?? (() => {}); const log = opts?.onProgress ?? (() => {});
const wantExif = opts?.exif ?? false; const wantExif = opts?.exif ?? false;
@@ -166,28 +164,24 @@ export const runMetadataBackup = async (
} }
// One failed request (retries exhausted) must not end the dump: its files // One failed request (retries exhausted) must not end the dump: its files
// get the reason in `mlDataError` and the other batches go on. So does a // get the reason in `mlDataError` and the other batches go on.
// file whose ML data does not decrypt, gunzip or parse.
log("Fetching ML data (face detections, CLIP embeddings)..."); log("Fetching ML data (face detections, CLIP embeddings)...");
const mlDataMap = new Map<number, MLData>(); const mlDataMap = new Map<number, MLData>();
const mlDataErrors = new Map<number, string>(); const mlDataErrors = new Map<number, string>();
let failedMLBatches = 0;
const fileIDs = [...fileKeys.keys()]; const fileIDs = [...fileKeys.keys()];
for (let i = 0; i < fileIDs.length; i += MLDATA_BATCH_SIZE) { for (let i = 0; i < fileIDs.length; i += MLDATA_BATCH_SIZE) {
const batch = fileIDs.slice(i, i + MLDATA_BATCH_SIZE); const batch = fileIDs.slice(i, i + MLDATA_BATCH_SIZE);
try { try {
const { payloads, errors } = await fetchMLDataBatch( const result = await fetchMLDataBatch(
client.getApiClient(), client.getApiClient(),
batch, batch,
fileKeys, fileKeys,
); );
for (const [id, payload] of payloads) mlDataMap.set(id, payload); for (const [id, payload] of result) mlDataMap.set(id, payload);
for (const [id, err] of errors) {
const reason = describeError(err);
log(`ML data of file ${id} could not be read: ${reason}`);
mlDataErrors.set(id, reason);
}
} catch (err) { } catch (err) {
const reason = err instanceof Error ? err.message : String(err); const reason = err instanceof Error ? err.message : String(err);
failedMLBatches++;
log( log(
`ML data request for ${batch.length} file(s) failed: ${reason}`, `ML data request for ${batch.length} file(s) failed: ${reason}`,
); );
@@ -235,5 +229,5 @@ export const runMetadataBackup = async (
} }
log("Metadata backup complete."); log("Metadata backup complete.");
return { filesWithMLDataError: mlDataErrors.size }; return { failedMLBatches };
}; };
+24 -33
View File
@@ -28,52 +28,43 @@ interface RawRemoteFileData {
updatedAt?: number; updatedAt?: number;
} }
// 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. Returns
// when the entry does not decrypt, gunzip or parse, or parses to something // undefined when the key is unknown or the entry does not decrypt/parse, so one
// other than a JSON object, such as `null`, a number or an array. // corrupt file never fails a whole batch.
const decodeEntry = (entry: RawRemoteFileData, key: Uint8Array): MLData => { const decodeEntry = (
const decrypted = decryptBlob( entry: RawRemoteFileData,
fromBase64(entry.encryptedData), key: Uint8Array | undefined,
fromBase64(entry.decryptionHeader), ): MLData | undefined => {
key, if (!key) return undefined;
); try {
const json = gunzipSync(Buffer.from(decrypted)).toString("utf-8"); const decrypted = decryptBlob(
const payload: unknown = JSON.parse(json); fromBase64(entry.encryptedData),
if ( fromBase64(entry.decryptionHeader),
typeof payload !== "object" || key,
payload === null || );
Array.isArray(payload) const json = gunzipSync(Buffer.from(decrypted)).toString("utf-8");
) { return JSON.parse(json) as MLData;
throw new Error("not a JSON object"); } catch {
return undefined;
} }
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
// the unit the request pools schedule; callers with more ids split them into // the unit the request pools schedule; callers with more ids split them into
// batches and submit each batch to the pool. Returns the payload of each file // batches and submit each batch to the pool.
// whose entry decodes and, in `errors`, why not for each file whose entry does
// not, so one corrupt file never fails a whole batch. An entry for a file
// whose key is not known, which quak did not ask for, is skipped.
export const fetchMLDataBatch = async ( export const fetchMLDataBatch = async (
api: ApiClient, api: ApiClient,
fileIDs: number[], fileIDs: number[],
fileKeys: Map<number, Uint8Array>, fileKeys: Map<number, Uint8Array>,
): Promise<{ payloads: Map<number, MLData>; errors: Map<number, unknown> }> => { ): Promise<Map<number, MLData>> => {
const { data } = await api.postJSON<{ data: RawRemoteFileData[] }>( const { data } = await api.postJSON<{ data: RawRemoteFileData[] }>(
"/files/data/fetch", "/files/data/fetch",
{ type: "mldata", fileIDs }, { type: "mldata", fileIDs },
); );
const payloads = new Map<number, MLData>(); const result = new Map<number, MLData>();
const errors = new Map<number, unknown>();
for (const entry of data ?? []) { for (const entry of data ?? []) {
const key = fileKeys.get(entry.fileID); const payload = decodeEntry(entry, fileKeys.get(entry.fileID));
if (!key) continue; if (payload) result.set(entry.fileID, payload);
try {
payloads.set(entry.fileID, decodeEntry(entry, key));
} catch (err) {
errors.set(entry.fileID, err);
}
} }
return { payloads, errors }; return result;
}; };
+22 -38
View File
@@ -10,7 +10,7 @@
// declining to retry a transient one costs a single file that the next run // declining to retry a transient one costs a single file that the next run
// picks up anyway. When the evidence is ambiguous, fail fast. // picks up anyway. When the evidence is ambiguous, fail fast.
import { ApiError, causeChain, TruncatedStreamError } from "./errors.js"; import { ApiError, TruncatedStreamError } from "./errors.js";
export interface RetryOptions { export interface RetryOptions {
// Total calls, not retries: `attempts: 1` disables retrying. // Total calls, not retries: `attempts: 1` disables retrying.
@@ -58,14 +58,6 @@ export const resolveRetryOptions = (
}); });
// Transport failures: the request did not complete, for reasons below HTTP. // Transport failures: the request did not complete, for reasons below HTTP.
//
// The errnos are Node's. The binary `make install` builds runs under bun,
// whose `fetch` rejects with a plain `Error` that has no `cause` and a `code`
// of bun's own. Bun gives `ConnectionRefused` for a refused connection and for
// a TLS handshake that failed, and `ConnectionRefused` or `FailedToOpenSocket`
// for a name that does not resolve. For a connection reset or closed before
// the response ended, it gives Node's `ECONNRESET`. These were found by
// running that binary, built with bun 1.3.13, against listeners on 127.0.0.1.
const TRANSPORT_CODES = new Set([ const TRANSPORT_CODES = new Set([
"ECONNRESET", "ECONNRESET",
"ECONNABORTED", "ECONNABORTED",
@@ -78,9 +70,6 @@ const TRANSPORT_CODES = new Set([
"ENETUNREACH", "ENETUNREACH",
"ENETRESET", "ENETRESET",
"ENETDOWN", "ENETDOWN",
// Bun's.
"ConnectionRefused",
"FailedToOpenSocket",
]); ]);
// The subset of the above that can only be reported before a TCP connection // The subset of the above that can only be reported before a TCP connection
@@ -98,14 +87,13 @@ const TRANSPORT_CODES = new Set([
// ambiguity this set exists to exclude. They stay in `TRANSPORT_CODES`, so // ambiguity this set exists to exclude. They stay in `TRANSPORT_CODES`, so
// they remain retryable for idempotent calls; only replay eligibility is // they remain retryable for idempotent calls; only replay eligibility is
// narrowed. See `isSafeToReplay`. // narrowed. See `isSafeToReplay`.
//
// Bun's `ConnectionRefused` and `FailedToOpenSocket` are absent too, so a
// `POST` or `PUT` is not replayed on them. When a connection bun kept open from
// an earlier request closes before any answer, bun sends the request again on
// a new connection, and if that one fails it gives one of these codes although
// the server may already have the request.
const CONNECT_CODES = new Set(["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED"]); const CONNECT_CODES = new Set(["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED"]);
// `cause` is an arbitrary user-settable property and nothing prevents it from
// forming a cycle, so the walk is bounded. Hanging the process would be a
// worse outcome than any misclassification.
const MAX_CAUSE_DEPTH = 8;
// Collect every `code` in an error's cause chain. undici does not put the // Collect every `code` in an error's cause chain. undici does not put the
// errno on the error it throws — it hangs the underlying socket error off // errno on the error it throws — it hangs the underlying socket error off
// `cause`, sometimes more than one level down — so a classifier that only read // `cause`, sometimes more than one level down — so a classifier that only read
@@ -113,21 +101,20 @@ const CONNECT_CODES = new Set(["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED"]);
// connection permanent. `complete` is false when the walk stopped at the // connection permanent. `complete` is false when the walk stopped at the
// depth limit with more of the chain still below it. // depth limit with more of the chain still below it.
const causeCodes = (err: unknown): { codes: string[]; complete: boolean } => { const causeCodes = (err: unknown): { codes: string[]; complete: boolean } => {
const { chain, complete } = causeChain(err);
const codes: string[] = []; const codes: string[] = [];
for (const link of chain) { let current: unknown = err;
const { code } = link as { code?: unknown }; for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) {
if (current === null || typeof current !== "object") {
return { codes, complete: true };
}
const { code, cause } = current as { code?: unknown; cause?: unknown };
if (typeof code === "string") codes.push(code); if (typeof code === "string") codes.push(code);
if (cause === current) return { codes, complete: true };
current = cause;
} }
return { codes, complete }; return { codes, complete: current === null || typeof current !== "object" };
}; };
// Does the error's cause chain hold the code of a transport failure, one of
// `TRANSPORT_CODES`? `isRetryable` retries such an error, and `classify` in
// `src/backup.ts` calls it transient.
export const hasTransportCode = (err: unknown): boolean =>
causeCodes(err).codes.some((code) => TRANSPORT_CODES.has(code));
const isAbort = (err: unknown): boolean => { const isAbort = (err: unknown): boolean => {
if (err === null || typeof err !== "object") return false; if (err === null || typeof err !== "object") return false;
const { name } = err as { name?: unknown }; const { name } = err as { name?: unknown };
@@ -169,10 +156,9 @@ export const isRetryable = (err: unknown): boolean => {
// deliberately literal. Demanding a recognised `cause` instead would // deliberately literal. Demanding a recognised `cause` instead would
// classify real network failures as permanent and fail backups that should // classify real network failures as permanent and fail backups that should
// have succeeded; the cost of the imprecision is bounded by the attempt // have succeeded; the cost of the imprecision is bounded by the attempt
// count. Bun's fetch rejects with a plain `Error` instead, which is // count.
// recognised by its code (see `TRANSPORT_CODES`).
if (err instanceof TypeError) return true; if (err instanceof TypeError) return true;
return hasTransportCode(err); return causeCodes(err).codes.some((code) => TRANSPORT_CODES.has(code));
}; };
// Could the first attempt already have taken effect on the server? // Could the first attempt already have taken effect on the server?
@@ -188,13 +174,11 @@ export const isRetryable = (err: unknown): boolean => {
// //
// Everything else is ambiguous. A 5xx proves the server did process the // Everything else is ambiguous. A 5xx proves the server did process the
// request. A reset or a broken pipe can arrive after it was fully sent and // request. A reset or a broken pipe can arrive after it was fully sent and
// acted on. A routing errno can be delivered on an established socket. Bun's // acted on. A routing errno can be delivered on an established socket. A
// `ConnectionRefused` and `FailedToOpenSocket` can come from a new connection // deadline says nothing at all about the server's state. So every errno in the
// bun opened to resend a request that already went out on a connection it kept // cause chain must be a connect errno: one other errno anywhere in the chain
// open. A deadline says nothing at all about the server's state. So every code in the // is doubt, and doubt is not replayed. A chain longer than the walk is doubt
// cause chain must be one of `CONNECT_CODES`: one other code anywhere in the // too: the links below the limit were never read.
// chain is doubt, and doubt is not replayed. A chain longer than the walk is
// doubt too: the links below the limit were never read.
export const isSafeToReplay = (err: unknown): boolean => { export const isSafeToReplay = (err: unknown): boolean => {
const { codes, complete } = causeCodes(err); const { codes, complete } = causeCodes(err);
return ( return (
-608
View File
@@ -1,608 +0,0 @@
/**
* How many originals `lib.backup()` works on at once.
*
* The backup puts up to the content pool's limit of originals at their save
* paths at the same time: 5 for a library opened with the default
* `RequestPools`, as `quak backup` opens it, or the `contentConcurrency` of the
* pools a library was opened with. Everything the backup does for one original
* counts against that limit, hashing it for `verify` included, so however large
* the account is, the backup has no more than that many originals open at once.
*
* As in `backup-end-to-end.test.ts`, the real `Client` and `Library` run
* against `buildMetaFetch` from `test/ente-server.ts`, a fake of the Ente HTTP
* API serving data this file encrypts with libsodium. Here a test can also hold
* each original's download until it lets it go, to see how many the backup has
* in flight at once.
*
* The account, user 42, has one album of its own, Holiday, of eight photos,
* all taken at noon on 1 March 2024, each with an original of its own.
*/
import {
existsSync,
lstatSync,
mkdtempSync,
readdirSync,
readFileSync,
readlinkSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import sodium from "libsodium-wrappers-sumo";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { BackupOptions } from "../../src/backup.js";
import { Client, type ClientSnapshot } from "../../src/client.js";
import { init, toBase64 } from "../../src/crypto/index.js";
import { Library } from "../../src/library/index.js";
import { RequestPools } from "../../src/library/pools.js";
import {
buildAccount,
buildMetaFetch,
encryptSecretbox,
encryptStreamBlob,
} from "../ente-server.js";
import { blake2b } from "../live-photo.js";
const EMAIL = "backup@example.com";
const PASSWORD = "backup-password";
// The fake server logs every account in as user 42.
const USER_ID = 42;
// Noon on 1 March 2024 in the time zone of the machine running the test, in
// microseconds, as Ente records times.
const TAKEN = new Date(2024, 2, 1, 12).getTime() * 1000;
// The eight photos' IDs. Photo `id` is titled `photo-<id>.jpg`, and its
// original is a few bytes naming it.
const PHOTO_IDS = [101, 102, 103, 104, 105, 106, 107, 108];
const originalOf = (id: number): Uint8Array =>
new TextEncoder().encode(`the photo ${id}`);
// Where a backup to `dir` saves photo `id`'s original.
const savedAt = (dir: string, id: number): string =>
join(dir, "2024/2024-03/2024-03-01", `2024-03-01.${id}.jpg`);
// The fake server, the session it logs the account in with, and the
// directory every backup below is made in.
let server: typeof globalThis.fetch;
let session: ClientSnapshot;
let root: string;
// The fake server, holding the account encrypted as Ente's clients upload it.
const buildServer = async () => {
const account = await buildAccount(PASSWORD);
// The album as `/collections/v2` lists it: its key encrypted with the
// account's master key, its name with its own key.
const albumKey = sodium.crypto_secretbox_keygen();
const encryptedAlbumKey = encryptSecretbox(albumKey, account.masterKey);
const encryptedName = encryptSecretbox(
new TextEncoder().encode("Holiday"),
albumKey,
);
const album = {
id: 10,
owner: { id: USER_ID },
encryptedKey: toBase64(encryptedAlbumKey.ciphertext),
keyDecryptionNonce: toBase64(encryptedAlbumKey.nonce),
encryptedName: toBase64(encryptedName.ciphertext),
nameDecryptionNonce: toBase64(encryptedName.nonce),
type: "album",
updationTime: TAKEN,
};
// Each photo as the album's `/collections/v2/diff` lists it: its key
// encrypted with the album's key, and its metadata, holding the hash of
// its original, encrypted with its own key. Its original, encrypted with
// its own key too, is what the files CDN serves for it.
const ciphertexts: Record<number, Uint8Array> = {};
const photos = PHOTO_IDS.map((id) => {
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const original = encryptStreamBlob(originalOf(id), key);
ciphertexts[id] = original.ciphertext;
const encryptedKey = encryptSecretbox(key, albumKey);
const metadata = encryptStreamBlob(
new TextEncoder().encode(
JSON.stringify({
title: `photo-${id}.jpg`,
fileType: 0,
hash: blake2b(originalOf(id)),
creationTime: TAKEN,
modificationTime: TAKEN,
}),
),
key,
);
return {
id,
collectionID: 10,
ownerID: USER_ID,
encryptedKey: toBase64(encryptedKey.ciphertext),
keyDecryptionNonce: toBase64(encryptedKey.nonce),
metadata: {
encryptedData: toBase64(metadata.ciphertext),
decryptionHeader: toBase64(metadata.header),
},
file: { decryptionHeader: toBase64(original.header) },
thumbnail: {
decryptionHeader: toBase64(sodium.randombytes_buf(24)),
},
updationTime: TAKEN,
};
});
return buildMetaFetch({
verifier: account.verifier,
srpAttributes: account.srpAttributes,
keyAttributes: account.keyAttributes,
encryptedToken: account.encryptedToken,
collections: [album],
filesByCollection: { 10: photos },
encryptedMLData: {},
fileCiphertexts: ciphertexts,
});
};
beforeAll(async () => {
await init();
await sodium.ready;
root = mkdtempSync(join(tmpdir(), "quak-backup-concurrency-"));
server = await buildServer();
const client = await Client.login({
email: EMAIL,
password: PASSWORD,
apiOptions: { fetch: server },
});
session = client.toJSON();
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });
});
// The ID of the photo whose original a request downloads, or undefined for any
// other request. quak downloads an original from
// `https://files.ente.io/?fileID=<id>`.
const downloadOf = (input: RequestInfo | URL): number | undefined => {
const url = new URL(String(input));
return url.hostname === "files.ente.io"
? Number(url.searchParams.get("fileID"))
: undefined;
};
// The fake server, except that it answers 404 to every download of photo
// `failing`'s original.
const failingFor =
(failing: number): typeof globalThis.fetch =>
async (input, init) =>
downloadOf(input) === failing
? new Response("not found", { status: 404 })
: server(input, init);
// The fake server, holding each download of an original until the test lets
// it go. `held` lists the downloads waiting, the first to arrive first;
// `most()` is the largest number that ever waited at once. A held download of
// photo `failing`'s original is answered 404 once it is let go.
const holdingServer = (failing?: number) => {
const held: { id: number; release: () => void }[] = [];
let most = 0;
const fetch: typeof globalThis.fetch = async (input, init) => {
const id = downloadOf(input);
if (id !== undefined) {
await new Promise<void>((release) => {
held.push({ id, release });
most = Math.max(most, held.length);
});
if (id === failing) {
return new Response("not found", { status: 404 });
}
}
return server(input, init);
};
return { fetch, held, most: () => most };
};
// Let the held downloads go one at a time, the first to arrive first, each
// once `limit` of them wait, or all the downloads still to come when there
// are fewer. Each of the eight photos is downloaded once. With a backup that
// downloads fewer than `limit` at once, this fails waiting for them. Each wait
// allows five seconds, for a loaded machine's disk to sync the files written.
const releaseOneAtATime = async (
held: { release: () => void }[],
limit: number,
): Promise<void> => {
for (let left = PHOTO_IDS.length; left > 0; left--) {
await vi.waitFor(
() => expect(held).toHaveLength(Math.min(limit, left)),
{ interval: 5, timeout: 5000 },
);
held.shift()!.release();
}
};
// A library of the account opened as `quak backup` opens one, with no
// precache and no background refresh, sending its requests through `fetch`.
// It backs up to `dir` and keeps its cache beside it. Its request pools are
// `pools`, or by default the library's own.
const openLibrary = (
dir: string,
fetch: typeof globalThis.fetch,
pools?: RequestPools,
): Promise<Library> =>
Library.open({
client: Client.fromJSON(session, { fetch }),
downloadDirectory: dir,
cacheDirectory: `${dir}-cache`,
refreshIntervalSeconds: Infinity,
precacheThumbnails: false,
precacheOriginals: false,
pools,
});
// Every entry under `dir`, relative to it, sorted, a symlink as
// `name -> target`.
const listTree = (dir: string): string[] =>
readdirSync(dir, { recursive: true })
.sort()
.map((name) => {
const path = join(dir, name);
return lstatSync(path).isSymbolicLink()
? `${name} -> ${readlinkSync(path)}`
: name;
});
const expectStored = (dir: string, ids: number[]): void => {
for (const id of ids) {
expect(readFileSync(savedAt(dir, id))).toEqual(
Buffer.from(originalOf(id)),
);
}
};
describe("lib.backup()", () => {
// A library opened with the default pools, as `quak backup` opens one,
// downloads up to five originals at once. As soon as one finishes, the
// backup starts on the next photo, so five stay in flight until fewer
// than five are left.
it("downloads five originals at once, and never more, with the default pools", async () => {
const dir = mkdtempSync(join(root, "backup-"));
const holding = holdingServer();
const lib = await openLibrary(dir, holding.fetch);
try {
const backup = lib.backup();
await releaseOneAtATime(holding.held, 5);
const result = await backup;
expect(holding.most()).toBe(5);
expect(result).toMatchObject({
totalFiles: 8,
downloaded: 8,
failed: 0,
});
expectStored(dir, PHOTO_IDS);
} finally {
await lib.close();
}
});
// The limit is the content pool's, so a library opened with pools of
// another `contentConcurrency` backs up that many at once.
it("downloads as many originals at once as the content pool of the pools it was opened with allows", async () => {
const dir = mkdtempSync(join(root, "backup-"));
const holding = holdingServer();
const lib = await openLibrary(
dir,
holding.fetch,
new RequestPools({ contentConcurrency: 2 }),
);
try {
const backup = lib.backup();
await releaseOneAtATime(holding.held, 2);
const result = await backup;
expect(holding.most()).toBe(2);
expect(result).toMatchObject({ downloaded: 8, failed: 0 });
expectStored(dir, PHOTO_IDS);
} finally {
await lib.close();
}
});
// With `verify`, hashing an original already stored is part of the work
// on it, and counts against the limit too: a large backup never has more
// than five originals open for hashing. Here every stored original has
// changed on disk, so each is hashed, found not to match, and downloaded
// again. While five downloads wait, the backup has hashed only those five
// photos' originals; it opens the next one once a download finishes.
it("with verify, hashes no more originals ahead of their downloads than the limit", async () => {
const dir = mkdtempSync(join(root, "backup-"));
const first = await openLibrary(dir, server);
try {
await first.backup();
} finally {
await first.close();
}
for (const id of PHOTO_IDS) {
writeFileSync(savedAt(dir, id), "not the photo any more");
}
const holding = holdingServer();
const lib = await openLibrary(dir, holding.fetch);
const lines: string[] = [];
const mismatches = () =>
lines.filter((line) => line.startsWith("MISMATCH"));
try {
const backup = lib.backup({
verify: true,
onProgress: (line) => lines.push(line),
});
await vi.waitFor(() => expect(holding.held).toHaveLength(5), {
interval: 5,
timeout: 5000,
});
expect(mismatches()).toHaveLength(5);
await releaseOneAtATime(holding.held, 5);
const result = await backup;
expect(holding.most()).toBe(5);
expect(mismatches()).toHaveLength(8);
expect(result).toMatchObject({
downloaded: 8,
verified: 0,
mismatched: 8,
failed: 0,
});
expectStored(dir, PHOTO_IDS);
} finally {
await lib.close();
}
});
// The server answers 404 for photo 103's original. A 404 is not retried,
// so that download fails at once, while other downloads are still in
// flight. The backup records the failure and goes on: the downloads
// beside it finish, the photos after it are downloaded, and only photo
// 103 is missing from the backup and in failures.json.
it("goes on with the other originals when one fails", async () => {
const dir = mkdtempSync(join(root, "backup-"));
const holding = holdingServer(103);
const lib = await openLibrary(dir, holding.fetch);
try {
const backup = lib.backup();
await releaseOneAtATime(holding.held, 5);
const result = await backup;
expect(result).toMatchObject({
totalFiles: 8,
downloaded: 7,
failed: 1,
});
expect(result.errors.map((e) => e.fileID)).toEqual([103]);
expectStored(
dir,
PHOTO_IDS.filter((id) => id !== 103),
);
expect(existsSync(savedAt(dir, 103))).toBe(false);
const failures = JSON.parse(
readFileSync(join(dir, "failures.json"), "utf-8"),
);
expect(Object.keys(failures.files)).toEqual(["103"]);
} finally {
await lib.close();
}
});
// A throw from `onProgress` fails the backup: `lib.backup()` rejects with
// it. From then on the backup starts no further original. It finishes the
// ones it is working on, and rejects only once they are in place, so
// nothing is written into the backup after it has ended and removed its
// lock. Here the first five downloads wait. Photo 103's is let go first
// and answered 404, and the line reporting that failure throws while the
// other four wait. Only those five downloads are held: a backup that went
// on would store the other three photos.
it("when onProgress throws, starts no further original, and rejects once the originals it was working on are in place", async () => {
const dir = mkdtempSync(join(root, "backup-"));
const downloads: number[] = [];
const held: { id: number; release: () => void }[] = [];
const fetch: typeof globalThis.fetch = async (input, init) => {
const id = downloadOf(input);
if (id !== undefined) {
downloads.push(id);
if (downloads.length <= 5) {
await new Promise<void>((release) =>
held.push({ id, release }),
);
}
if (id === 103) {
return new Response("not found", { status: 404 });
}
}
return server(input, init);
};
const lib = await openLibrary(dir, fetch);
const lines: string[] = [];
try {
const backup = lib.backup({
onProgress: (line) => {
lines.push(line);
if (line.startsWith("FAILED")) {
throw new Error("the progress output is closed");
}
},
});
// The photos whose originals are in place, and whether the lock
// is there, at the moment `lib.backup()` rejects.
let storedWhenRejected: number[] = [];
let lockedWhenRejected = true;
const rejection = backup.catch((err: unknown) => {
storedWhenRejected = PHOTO_IDS.filter((id) =>
existsSync(savedAt(dir, id)),
);
lockedWhenRejected = existsSync(join(dir, "backup.lock"));
return err;
});
await vi.waitFor(() => expect(held).toHaveLength(5), {
interval: 5,
timeout: 5000,
});
const at103 = held.findIndex((h) => h.id === 103);
held.splice(at103, 1)[0]!.release();
await vi.waitFor(
() =>
expect(
lines.some((line) => line.startsWith("FAILED")),
).toBe(true),
{ interval: 5, timeout: 5000 },
);
for (const download of held) download.release();
expect(await rejection).toEqual(
new Error("the progress output is closed"),
);
expect(downloads.sort()).toEqual([101, 102, 103, 104, 105]);
expect(storedWhenRejected).toEqual([101, 102, 104, 105]);
expect(lockedWhenRejected).toBe(false);
} finally {
await lib.close();
}
});
// Once the backup's lock is lost, another backup may be writing to the
// directory, so the backup starts no further original. It finishes the
// ones it is working on, and `lib.backup()` rejects once they are all in
// place. Here `backup.lock` is removed while the first five downloads
// wait, and proper-lockfile's timer finds it gone. As in
// `backup-end-to-end.test.ts`, only `setTimeout` and `clearTimeout` are
// faked, so the test can fire that timer without waiting 5 seconds. Only
// the first five downloads are held: a backup that went on would store
// the other three photos.
it("when its lock is lost, starts no further original, and rejects once the originals it was working on are in place", async () => {
const dir = mkdtempSync(join(root, "backup-"));
const lockPath = join(dir, "backup.lock");
const downloads: number[] = [];
const held: (() => void)[] = [];
const fetch: typeof globalThis.fetch = async (input, init) => {
const id = downloadOf(input);
if (id !== undefined) {
downloads.push(id);
if (downloads.length <= 5) {
await new Promise<void>((release) => held.push(release));
}
}
return server(input, init);
};
const lib = await openLibrary(dir, fetch);
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
const backup = lib.backup();
// The photos whose originals are in place at the moment
// `lib.backup()` rejects.
let storedWhenRejected: number[] = [];
const rejection = backup.catch((err: unknown) => {
storedWhenRejected = PHOTO_IDS.filter((id) =>
existsSync(savedAt(dir, id)),
);
return err;
});
await vi.waitFor(() => expect(held).toHaveLength(5), {
interval: 5,
timeout: 5000,
});
rmSync(lockPath, { recursive: true });
// Fire proper-lockfile's timer, and wait for the look at the lock
// on disk that it starts to finish: Node lists a file system call
// in progress as an `FSReqCallback`.
vi.advanceTimersByTime(5_000);
while (process.getActiveResourcesInfo().includes("FSReqCallback")) {
await new Promise((resolve) => setImmediate(resolve));
}
for (const release of held) release();
expect(await rejection).toEqual(
new Error(
`lost the lock on ${dir}: ENOENT: no such file or directory, stat '${lockPath}'`,
),
);
expect(downloads.sort()).toEqual([101, 102, 103, 104, 105]);
expect(storedWhenRejected).toEqual([101, 102, 103, 104, 105]);
} finally {
vi.useRealTimers();
await lib.close();
}
});
// Working on several originals at once changes only how long a backup
// takes. The same two runs, with a content pool of one, which backs up
// one original at a time, and with the default pools, end the same way:
// the same result, the same progress lines (in another order), the same
// failures.json, and the same tree. The first run stores every photo but
// 103, whose download is answered 404. Then two stored originals change
// on disk, and a run with `verify` puts them back and tries 103 again.
it("ends as a backup of one original at a time does", async () => {
const twoRuns = async (pools: RequestPools) => {
const dir = mkdtempSync(join(root, "backup-"));
const lines: string[] = [];
const backup = async (opts: BackupOptions) => {
const lib = await openLibrary(dir, failingFor(103), pools);
try {
return await lib.backup({
...opts,
onProgress: (line) => lines.push(line),
});
} finally {
await lib.close();
}
};
const first = await backup({});
writeFileSync(savedAt(dir, 101), "not the photo any more");
writeFileSync(savedAt(dir, 105), "not the photo any more");
const second = await backup({ verify: true });
// Each entry of failures.json without the time it was last
// tried, which differs from run to run.
const { files } = JSON.parse(
readFileSync(join(dir, "failures.json"), "utf-8"),
) as { files: Record<string, { lastTriedAt?: number }> };
for (const entry of Object.values(files)) delete entry.lastTriedAt;
return {
first,
second,
lines: lines.sort(),
failures: files,
tree: listTree(dir),
};
};
const oneAtATime = await twoRuns(
new RequestPools({ contentConcurrency: 1 }),
);
const fiveAtATime = await twoRuns(new RequestPools());
expect(oneAtATime.first).toMatchObject({
totalFiles: 8,
downloaded: 7,
skipped: 0,
failed: 1,
});
expect(oneAtATime.second).toMatchObject({
totalFiles: 8,
downloaded: 2,
skipped: 5,
verified: 5,
mismatched: 2,
unchecked: 0,
failed: 1,
});
expect(oneAtATime.failures).toEqual({
"103": expect.objectContaining({ fileID: 103, attempts: 2 }),
});
expect(fiveAtATime).toEqual(oneAtATime);
});
});
File diff suppressed because it is too large Load Diff
-554
View File
@@ -1,554 +0,0 @@
/**
* `quak backup` over an account holding an album or a file quak cannot decrypt
* (issue 181).
*
* The server sends every album and file encrypted, and a refresh decrypts each
* one it fetches. A record whose key no key of the account opens, or whose
* metadata is not JSON, must not fail the refresh: a refresh that fails commits
* nothing, so `quak backup` would store nothing, on every run, until the
* server's copy changed. Instead the refresh leaves that record out, and the
* library keeps a list of what it left out in its cache. It has to: the next
* refresh asks the server only for what changed since the last one, so it will
* not see the record again.
*
* The backup stores every other file. It records each file it could not
* decrypt in `failures.json`, prints one line for each album it could not
* decrypt, and exits 1 while any remain. Once the server sends a copy that
* decrypts, or deletes the record, the next run drops it and can exit 0. The
* read commands answer from what did decrypt.
*
* These tests run the commands with a real saved session over a stand-in Ente
* server, which serves albums, files and originals encrypted with libsodium as
* the real server does. A record is broken the way a corrupt one arrives: its
* key replaced by bytes no key opens, or its metadata text that is not JSON.
*/
import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import sodium from "libsodium-wrappers-sumo";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
backupCommand,
collectionsCommand,
filesCommand,
saveSession,
type CliContext,
} from "../../src/cli-commands.js";
import { loadSession } from "../../src/cli-session.js";
import { Client, type ClientSnapshot } from "../../src/client.js";
import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js";
import { Library } from "../../src/library/index.js";
const USER_ID = 42;
// Every file is taken at noon local time on 2026-03-01, in microseconds as Ente
// stores times, so its original is saved in the folder `DAY` whatever the time
// zone of the machine running the tests.
const TAKEN = new Date(2026, 2, 1, 12).getTime() * 1000;
const DAY = join("2026", "2026-03", "2026-03-01");
type Row = { id: number; updationTime: number } & Record<string, unknown>;
// `plaintext` sealed under `key` with a fresh nonce, both base64, as Ente
// sends a key or an album name.
const secretbox = (
plaintext: Uint8Array,
key: Uint8Array,
): { ciphertext: string; nonce: string } => {
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
return {
ciphertext: toBase64(
sodium.crypto_secretbox_easy(plaintext, nonce, key),
),
nonce: toBase64(nonce),
};
};
// What a corrupt row carries in place of its sealed key: random bytes of the
// same length, which no key opens.
const corruptKey = (): string =>
toBase64(
sodium.randombytes_buf(
sodium.crypto_secretbox_KEYBYTES + sodium.crypto_secretbox_MACBYTES,
),
);
const json = (body: unknown): Response =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
// A stand-in Ente server for one account. It holds each album, and each file in
// an album, as the row `/collections/v2` and `/collections/v2/diff` serve, and
// answers a request with the rows whose `updationTime` is past the request's
// `sinceTime`, as the real server does. A change to a file also moves its
// album's `updationTime`, which is what tells a client to list the album's
// files again. Originals are served by file ID, as the files CDN serves them.
class FakeEnte {
private readonly masterKey = sodium.crypto_secretbox_keygen();
private readonly keyPair = sodium.crypto_box_keypair();
private readonly albums = new Map<number, Row>();
private readonly albumKeys = new Map<number, Uint8Array>();
// Each album's file rows, by album ID and then file ID.
private readonly files = new Map<number, Map<number, Row>>();
// Each file's key and its original as served, by file ID. A file in two
// albums is one original under one key, sealed under each album's key.
private readonly originals = new Map<
number,
{ key: Uint8Array; header: string; body: Uint8Array }
>();
// The session `quak login` would have saved for the account.
snapshot(): ClientSnapshot {
return {
email: "user@example.com",
userID: USER_ID,
token: "token",
masterKey: toBase64(this.masterKey),
secretKey: toBase64(this.keyPair.privateKey),
publicKey: toBase64(this.keyPair.publicKey),
};
}
// Album `id`, named `name`, as of time `t`. With `corrupt`, its key cannot
// be decrypted. An album keeps its key from one call to the next, so its
// files' rows still decrypt once it is put back without `corrupt`.
putAlbum(id: number, name: string, t: number, corrupt = false): void {
const key = this.albumKeys.get(id) ?? sodium.crypto_secretbox_keygen();
this.albumKeys.set(id, key);
const sealedKey = secretbox(key, this.masterKey);
const sealedName = secretbox(new TextEncoder().encode(name), key);
this.albums.set(id, {
id,
owner: { id: USER_ID },
encryptedKey: corrupt ? corruptKey() : sealedKey.ciphertext,
keyDecryptionNonce: sealedKey.nonce,
encryptedName: sealedName.ciphertext,
nameDecryptionNonce: sealedName.nonce,
type: "album",
updationTime: t,
});
if (!this.files.has(id)) this.files.set(id, new Map());
}
// File `id`, titled `photo-<id>.jpg`, in album `albumID` as of time `t`.
// With `corruptKey`, its key cannot be decrypted. With `metadata`, its
// metadata decrypts to that text instead of the JSON a client uploads.
putFile(
albumID: number,
id: number,
t: number,
opts: { corruptKey?: boolean; metadata?: string } = {},
): void {
const original = this.originalOf(id);
const sealedKey = secretbox(original.key, this.albumKeys.get(albumID)!);
const metadata =
opts.metadata ??
JSON.stringify({
title: `photo-${id}.jpg`,
fileType: 0,
creationTime: TAKEN,
modificationTime: TAKEN,
});
const sealedMetadata = encryptBlob(
new TextEncoder().encode(metadata),
original.key,
);
this.files.get(albumID)!.set(id, {
id,
collectionID: albumID,
ownerID: USER_ID,
encryptedKey: opts.corruptKey ? corruptKey() : sealedKey.ciphertext,
keyDecryptionNonce: sealedKey.nonce,
metadata: {
encryptedData: toBase64(sealedMetadata.ciphertext),
decryptionHeader: toBase64(sealedMetadata.header),
},
file: { decryptionHeader: original.header },
thumbnail: { decryptionHeader: original.header },
updationTime: t,
});
this.moveAlbum(albumID, t);
}
// A deleted record stays on the server as a tombstone holding only its
// ID, its time and `isDeleted`.
deleteFile(albumID: number, id: number, t: number): void {
this.files
.get(albumID)!
.set(id, { id, updationTime: t, isDeleted: true });
this.moveAlbum(albumID, t);
}
deleteAlbum(id: number, t: number): void {
this.albums.set(id, { id, updationTime: t, isDeleted: true });
}
// The `fetch` the saved session's client is given.
readonly fetch = (async (input: string | URL | Request) => {
const url = new URL(input instanceof Request ? input.url : input);
const since = Number(url.searchParams.get("sinceTime"));
const changed = (rows: Iterable<Row>): Row[] =>
[...rows].filter((row) => row.updationTime > since);
switch (url.pathname) {
case "/collections/v2":
return json({ collections: changed(this.albums.values()) });
case "/collections/v2/diff": {
const albumID = Number(url.searchParams.get("collectionID"));
const rows = this.files.get(albumID)?.values() ?? [];
return json({ diff: changed(rows), hasMore: false });
}
case "/files/data/fetch":
// No file has ML data.
return json({ data: [] });
default: {
const fileID = Number(url.searchParams.get("fileID"));
const original = this.originals.get(fileID);
return original
? new Response(original.body)
: new Response("not found", { status: 404 });
}
}
}) as typeof globalThis.fetch;
private originalOf(id: number): {
key: Uint8Array;
header: string;
body: Uint8Array;
} {
const known = this.originals.get(id);
if (known) return known;
const key = sodium.crypto_secretbox_keygen();
const { header, ciphertext } = encryptBlob(
new TextEncoder().encode(`original of ${id}`),
key,
);
const original = { key, header: toBase64(header), body: ciphertext };
this.originals.set(id, original);
return original;
}
private moveAlbum(albumID: number, t: number): void {
this.albums.set(albumID, {
...this.albums.get(albumID)!,
updationTime: t,
});
}
}
// Album 1, "Trip", holds file 10, which decrypts, file 11, whose key does not,
// and file 12, whose metadata is not JSON. Album 2, "Family", holds file 20,
// but the album's own key does not decrypt. All of it as of time 1.
const brokenAccount = (): FakeEnte => {
const ente = new FakeEnte();
ente.putAlbum(1, "Trip", 1);
ente.putFile(1, 10, 1);
ente.putFile(1, 11, 1, { corruptKey: true });
ente.putFile(1, 12, 1, { metadata: "not json" });
ente.putAlbum(2, "Family", 1, true);
ente.putFile(2, 20, 1);
return ente;
};
// Album 1, "Trip", holds file 10, and album 2, "Family", holds file 20. Both
// files decrypt, but album 2's own key does not, so the album is the only
// record left out. All of it as of time 1.
const brokenAlbumAccount = (): FakeEnte => {
const ente = new FakeEnte();
ente.putAlbum(1, "Trip", 1);
ente.putFile(1, 10, 1);
ente.putAlbum(2, "Family", 1, true);
ente.putFile(2, 20, 1);
return ente;
};
// Collects everything written to it.
class Output {
text = "";
write(text: string): void {
this.text += text;
}
}
let root: string;
let stdout: Output;
let stderr: Output;
beforeAll(async () => {
await init();
await sodium.ready;
});
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "quak-undecryptable-"));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
// A context for one command, with fresh output, whose saved session talks to
// `ente`. Every run shares one cache directory, as runs of `quak` do.
const context = (ente: FakeEnte): CliContext => {
stdout = new Output();
stderr = new Output();
saveSession(join(root, "session"), ente.snapshot());
return {
stdout,
stderr,
sessionDir: join(root, "session"),
cacheDir: join(root, "cache"),
loadSession: (path) =>
loadSession(path, { fetch: ente.fetch, retry: { attempts: 1 } }),
login: async () => {
throw new Error("login not expected");
},
prompt: async () => {
throw new Error("prompt not expected");
},
promptSecret: async () => {
throw new Error("prompt not expected");
},
};
};
const backupDir = (): string => join(root, "backup");
// Runs `quak backup` into the backup directory and returns its exit code.
const backup = (ente: FakeEnte, opts: { json?: boolean } = {}) =>
backupCommand(context(ente), backupDir(), opts);
// The backup's `failures.json` entries by file ID, or {} when it has none.
const failures = (): Record<string, { error: string; attempts: number }> => {
const path = join(backupDir(), "failures.json");
return existsSync(path)
? JSON.parse(readFileSync(path, "utf-8")).files
: {};
};
// Whether file `id`'s original is at its save path in the backup.
const stored = (id: number): boolean =>
existsSync(join(backupDir(), DAY, `2026-03-01.${id}.jpg`));
// The IDs of the files that album `name`'s JSON lists.
const albumFiles = (name: string): number[] =>
JSON.parse(
readFileSync(join(backupDir(), "collections", `${name}.json`), "utf-8"),
).files.map((f: { id: number }) => f.id);
describe("quak backup with records it cannot decrypt", () => {
it("backs up every other file, records each file it cannot decrypt in failures.json, names each such album, and exits 1", async () => {
expect(await backup(brokenAccount())).toBe(1);
expect(stored(10)).toBe(true);
// Without its album's key, file 20 cannot even be listed.
expect(stored(20)).toBe(false);
expect(failures()).toEqual({
"11": expect.objectContaining({
fileID: 11,
error: expect.stringMatching(
/^could not be decrypted: .*wrong secret key/,
),
}),
"12": expect.objectContaining({
fileID: 12,
error: expect.stringMatching(/^could not be decrypted: .*JSON/),
}),
});
expect(stderr.text).toMatch(
/^FAILED album 2: could not be decrypted: .*wrong secret key/m,
);
expect(stderr.text).toContain(" Failed: 2\n");
});
it("names each album it cannot decrypt in the --json result", async () => {
expect(await backup(brokenAccount(), { json: true })).toBe(1);
expect(JSON.parse(stdout.text)).toMatchObject({
downloaded: 1,
failed: 2,
undecryptableAlbums: [2],
});
});
it("still reports them on the next run, though the server does not send them again", async () => {
const ente = brokenAccount();
await backup(ente);
// Nothing changed on the server, so this run's refresh gets no rows at
// all. What it reports, it remembered from the first run.
expect(await backup(ente)).toBe(1);
expect(Object.keys(failures())).toEqual(["11", "12"]);
expect(failures()["11"]!.attempts).toBe(2);
expect(stderr.text).toMatch(/^FAILED album 2: could not be decrypted/m);
});
it("drops each once the server sends a copy that decrypts, and then exits 0", async () => {
const ente = brokenAccount();
await backup(ente);
// At time 2 files 11 and 12 and album 2 change on the server, and now
// each decrypts. Album 2 brings file 20 with it.
ente.putFile(1, 11, 2);
ente.putFile(1, 12, 2);
ente.putAlbum(2, "Family", 2);
expect(await backup(ente)).toBe(0);
expect([11, 12, 20].map(stored)).toEqual([true, true, true]);
expect(existsSync(join(backupDir(), "failures.json"))).toBe(false);
expect(stderr.text).not.toContain("FAILED");
});
it("drops each once the server deletes it, and then exits 0", async () => {
const ente = brokenAccount();
await backup(ente);
ente.deleteFile(1, 11, 2);
ente.deleteFile(1, 12, 2);
ente.deleteAlbum(2, 2);
expect(await backup(ente)).toBe(0);
expect(existsSync(join(backupDir(), "failures.json"))).toBe(false);
expect(stderr.text).not.toContain("FAILED");
});
it("exits 1 when the only record it cannot decrypt is an album, and 0 once the album decrypts", async () => {
const ente = brokenAlbumAccount();
// No file is in `failures.json`: the album alone keeps the exit code
// at 1.
expect(await backup(ente)).toBe(1);
expect(failures()).toEqual({});
ente.putAlbum(2, "Family", 2);
expect(await backup(ente)).toBe(0);
expect(stored(20)).toBe(true);
});
it("exits 1 when the only record it cannot decrypt is an album, and 0 once the album is deleted", async () => {
const ente = brokenAlbumAccount();
expect(await backup(ente)).toBe(1);
expect(failures()).toEqual({});
ente.deleteAlbum(2, 2);
expect(await backup(ente)).toBe(0);
});
it("removes what an earlier run linked for an album or a file that no longer decrypts, and keeps the originals", async () => {
// At time 1 everything decrypts: the first run links files 10 and 11
// in Trip and file 20 in Family.
const ente = new FakeEnte();
ente.putAlbum(1, "Trip", 1);
ente.putFile(1, 10, 1);
ente.putFile(1, 11, 1);
ente.putAlbum(2, "Family", 1);
ente.putFile(2, 20, 1);
expect(await backup(ente)).toBe(0);
const collections = join(backupDir(), "collections");
expect(readdirSync(join(collections, "Trip")).sort()).toEqual([
"photo-10.jpg",
"photo-11.jpg",
]);
expect(readdirSync(join(collections, "Family"))).toEqual([
"photo-20.jpg",
]);
// At time 2 the server's copy of file 11 in Trip, and album 2 itself,
// no longer decrypt. A run builds the album folders, their JSON and
// their links from what the library holds, so it removes them as if
// the album and the file had been deleted: Family's folder and JSON
// go, and Trip neither links nor lists file 11. The originals stay at
// their save paths, and the run exits 1.
ente.putFile(1, 11, 2, { corruptKey: true });
ente.putAlbum(2, "Family", 2, true);
expect(await backup(ente)).toBe(1);
expect(readdirSync(collections).sort()).toEqual(["Trip", "Trip.json"]);
expect(readdirSync(join(collections, "Trip"))).toEqual([
"photo-10.jpg",
]);
expect(albumFiles("Trip")).toEqual([10]);
expect([10, 11, 20].map(stored)).toEqual([true, true, true]);
expect(Object.keys(failures())).toEqual(["11"]);
});
it("backs up a file that does not decrypt in one album but does in another, and does not report it", async () => {
// File 30 is in albums 1 and 3, each holding its key sealed under the
// album's own key. In album 1 that is corrupt; in album 3 it is not,
// and one copy that decrypts is all the backup needs.
const ente = new FakeEnte();
ente.putAlbum(1, "Trip", 1);
ente.putFile(1, 30, 1, { corruptKey: true });
ente.putAlbum(3, "Favorites", 1);
ente.putFile(3, 30, 1);
expect(await backup(ente)).toBe(0);
expect(stored(30)).toBe(true);
expect(failures()).toEqual({});
// Album 1 has no copy of file 30 that decrypts, so it neither links
// the original nor lists the file; album 3 lists it.
expect(readdirSync(join(backupDir(), "collections", "Trip"))).toEqual(
[],
);
expect(albumFiles("Trip")).toEqual([]);
expect(albumFiles("Favorites")).toEqual([30]);
});
it("backs up that file in a run limited to the album where it does not decrypt", async () => {
// The same account, backed up by `lib.backup()` limited to album 1,
// "Trip", as `quak backup` cannot be. File 30 is in Trip, so the run
// must store it, from its copy in album 3 that the run otherwise
// skips.
const ente = new FakeEnte();
ente.putAlbum(1, "Trip", 1);
ente.putFile(1, 30, 1, { corruptKey: true });
ente.putAlbum(3, "Favorites", 1);
ente.putFile(3, 30, 1);
const lib = await Library.open({
client: Client.fromJSON(ente.snapshot(), {
fetch: ente.fetch,
retry: { attempts: 1 },
}),
downloadDirectory: backupDir(),
cacheDirectory: join(root, "cache"),
precacheThumbnails: false,
precacheOriginals: false,
});
try {
const result = await lib.backup({
downloadDirectory: backupDir(),
onlyAlbumNames: ["Trip"],
});
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
} finally {
await lib.close();
}
expect(stored(30)).toBe(true);
expect(failures()).toEqual({});
});
});
describe("the read commands with records quak cannot decrypt", () => {
it("quak collections and quak files answer from what decrypts", async () => {
const ente = brokenAccount();
expect(await collectionsCommand(context(ente), {})).toBe(0);
expect(stdout.text).toBe("1\talbum\tTrip\n");
expect(await filesCommand(context(ente), { collection: "1" })).toBe(0);
expect(stdout.text).toBe("10\timage\tphoto-10.jpg\n");
});
});
+27 -363
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();
}); });
@@ -923,27 +909,6 @@ describe("the backup lock", () => {
expect(existsSync(lockPath(outDir))).toBe(false); expect(existsSync(lockPath(outDir))).toBe(false);
await lib.close(); await lib.close();
}); });
// `runBackup` takes a library object from its caller. With a
// `contentConcurrency` below 1, or one that is not a number, it would
// back up no original and report no error. It rejects any that is not a
// positive integer, as `BoundedPool` does, before it takes the lock,
// which would create the download directory, and before it asks the
// library for anything: the object here has nothing else.
it("rejects a library whose contentConcurrency is not a positive integer, before taking the lock", async () => {
const outDir = join(root, "backup");
for (const contentConcurrency of [undefined, 0, -1, 1.5, NaN]) {
const lib = { contentConcurrency } as unknown as BackupLibrary;
await expect(
runBackup(lib, { downloadDirectory: outDir }),
).rejects.toThrow(
`contentConcurrency must be a positive integer, got ${contentConcurrency}`,
);
expect(existsSync(outDir)).toBe(false);
}
});
}); });
// Also serves ML data: file 100 has `ML_PAYLOAD`, the other two have none. // Also serves ML data: file 100 has `ML_PAYLOAD`, the other two have none.
@@ -952,14 +917,13 @@ const ML_PAYLOAD = { face: { faces: [] }, clip: { embedding: [0.5, 0.25] } };
class MLClient extends MockClient { class MLClient extends MockClient {
mlError?: string; mlError?: string;
async fetchMLData(args: { fileIDs: number[] }): Promise<{ async fetchMLData(args: {
payloads: Map<number, MLData>; fileIDs: number[];
errors: Map<number, unknown>; }): Promise<Map<number, MLData>> {
}> {
if (this.mlError) throw new Error(this.mlError); if (this.mlError) throw new Error(this.mlError);
const payloads = new Map<number, MLData>(); const result = new Map<number, MLData>();
if (args.fileIDs.includes(100)) payloads.set(100, ML_PAYLOAD); if (args.fileIDs.includes(100)) result.set(100, ML_PAYLOAD);
return { payloads, errors: new Map() }; return result;
} }
} }
@@ -1309,8 +1273,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 +1316,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 +1360,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 +1379,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 +1403,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();
}); });
@@ -1552,29 +1477,21 @@ describe("backup album folders", () => {
files: EnteFile[]; files: EnteFile[];
} }
// The original of the file `notFound`, if given, always fails to download. const libraryOf = (albums: Album[]): BackupLibrary => ({
const libraryOf = (albums: Album[], notFound?: number): BackupLibrary => ({
refresh: async () => {}, refresh: async () => {},
listCollections: () => albums.map((a) => a.collection), listCollections: () => albums.map((a) => a.collection),
listFiles: (id) => listFiles: (id) =>
albums.find((a) => a.collection.id === id)?.files ?? [], albums.find((a) => a.collection.id === id)?.files ?? [],
undecryptable: () => [],
original: async (fileID) => { original: async (fileID) => {
if (fileID === notFound) throw new Error("HTTP 404");
const path = join(root, `source-${fileID}`); const path = join(root, `source-${fileID}`);
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");
}, },
contentConcurrency: 5,
fetchMLData: async () => {}, fetchMLData: async () => {},
mlData: async () => undefined, mlData: async () => undefined,
mlDataError: () => undefined,
whoami: () => ({ email: "backup@example.com", userID: USER_ID }), whoami: () => ({ email: "backup@example.com", userID: USER_ID }),
}); });
@@ -1660,252 +1577,6 @@ describe("backup album folders", () => {
expect(albumID(outDir, "Trip (11) (12).json")).toBe(12); expect(albumID(outDir, "Trip (11) (12).json")).toBe(12);
}); });
// The next three tests end with the album "ok" (14), whose one file
// always fails to download, so each run has a failure to record in
// failures.json after the album folders are written.
const ok: Album = {
collection: collection(14, "ok"),
files: [file(5, 14, "e.jpg")],
};
// The folder of an album named "x.json" would be the JSON file of the
// album "x", and once ended every run there. Two runs, as on two nights,
// write the same tree, and the failed file's attempt count goes up.
it("adds the ID to an album whose folder would have another album's JSON name", async () => {
const outDir = join(root, "backup");
const lib = libraryOf(
[
{
collection: collection(10, "x"),
files: [file(1, 10, "a.jpg")],
},
{
collection: collection(11, "x.json"),
files: [file(2, 11, "b.jpg")],
},
{
collection: collection(12, "Trip"),
files: [file(3, 12, "c.jpg")],
},
{
collection: collection(13, "TRIP.json"),
files: [file(4, 13, "d.jpg")],
},
ok,
],
5,
);
await runBackup(lib, { downloadDirectory: outDir });
const result = await runBackup(lib, { downloadDirectory: outDir });
expect(result.failedAlbums).toEqual([]);
expect(tree(outDir)).toEqual([
"TRIP.json (13)/",
`TRIP.json (13)/d.jpg -> ${linkTo("4.jpg")}`,
"TRIP.json (13).json",
"Trip/",
`Trip/c.jpg -> ${linkTo("3.jpg")}`,
"Trip.json",
"ok/",
"ok.json",
"x/",
`x/a.jpg -> ${linkTo("1.jpg")}`,
"x.json",
"x.json (11)/",
`x.json (11)/b.jpg -> ${linkTo("2.jpg")}`,
"x.json (11).json",
]);
expect(albumID(outDir, "x.json")).toBe(10);
expect(albumID(outDir, "x.json (11).json")).toBe(11);
expect(albumID(outDir, "TRIP.json (13).json")).toBe(13);
expect(albumID(outDir, "ok.json")).toBe(14);
expect(result.failed).toBe(1);
expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 2 });
});
// A name over 255 bytes is refused as a file name, and an album folder's
// name leaves room for `.json` in its JSON's. "é" takes two bytes, and a
// name is cut between two characters.
it("cuts an album name and a title too long for a file name short and adds the ID", async () => {
const outDir = join(root, "backup");
const lib = libraryOf(
[
{
collection: collection(10, "L".repeat(300)),
files: [file(1, 10, `${"é".repeat(150)}.jpg`)],
},
ok,
],
5,
);
await runBackup(lib, { downloadDirectory: outDir });
const result = await runBackup(lib, { downloadDirectory: outDir });
const folder = `${"L".repeat(245)} (10)`;
const link = `${"é".repeat(123)} (1).jpg`;
expect(Buffer.byteLength(`${folder}.json`)).toBe(255);
expect(Buffer.byteLength(link)).toBe(254);
expect(result.failedAlbums).toEqual([]);
expect(tree(outDir)).toEqual([
`${folder}/`,
`${folder}/${link} -> ${linkTo("1.jpg")}`,
`${folder}.json`,
"ok/",
"ok.json",
]);
expect(albumID(outDir, `${folder}.json`)).toBe(10);
expect(albumID(outDir, "ok.json")).toBe(14);
expect(result.failed).toBe(1);
expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 2 });
});
// The user has put a file in collections/ with the name of the album
// Trip's folder, so the folder cannot be made.
it("goes on to the next album when an album's folder cannot be made, and lists it as failed", async () => {
const outDir = join(root, "backup");
const lib = libraryOf(
[
{
collection: collection(10, "Trip"),
files: [file(1, 10, "a.jpg")],
},
ok,
],
5,
);
mkdirSync(join(outDir, "collections"), { recursive: true });
writeFileSync(join(outDir, "collections", "Trip"), "mine");
const logged: string[] = [];
const result = await runBackup(lib, {
downloadDirectory: outDir,
onProgress: (message) => logged.push(message),
});
expect(result.failedAlbums).toEqual([10]);
expect(logged).toContainEqual(
expect.stringMatching(/^FAILED album Trip: EEXIST: /),
);
expect(tree(outDir)).toEqual(["Trip", "ok/", "ok.json"]);
expect(albumID(outDir, "ok.json")).toBe(14);
expect(existsSync(saved(outDir, "1.jpg"))).toBe(true);
expect(result.failed).toBe(1);
expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 1 });
});
// The extensions of these titles leave no room for the ID, so each is cut
// short and the name before the ID keeps only the bytes that cut leaves
// over. File 1's extension is 303 bytes and is cut to 251, which leaves
// none. File 2's is 271 bytes and is cut to 250, since "€" takes three
// bytes, which leaves one, for the "a". Neither extension is only letters
// and digits, so both originals are saved with `.bin`, a name that fits.
it("cuts the extension of a title short when it leaves no room for the ID", async () => {
const outDir = join(root, "backup");
const lib = libraryOf([
{
collection: collection(10, "Trip"),
files: [
file(1, 10, `a.${"L".repeat(300)}-x`),
file(2, 10, `a.${"€".repeat(90)}`),
],
},
]);
const result = await runBackup(lib, { downloadDirectory: outDir });
const link = ` (1).${"L".repeat(250)}`;
const link2 = `a (2).${"€".repeat(83)}`;
expect(Buffer.byteLength(link)).toBe(255);
expect(Buffer.byteLength(link2)).toBe(255);
expect(result.failed).toBe(0);
expect(tree(outDir)).toEqual([
"Trip/",
`Trip/${link} -> ${linkTo("1.bin")}`,
`Trip/${link2} -> ${linkTo("2.bin")}`,
"Trip.json",
]);
});
// Files 1 and 3 share a title, so each gets its ID before the
// extension. File 2's title is too long, and its extension leaves no
// room for the ID. Were the ID put at the end of file 2's title cut
// short, its link would have file 1's name, and the link made second
// would replace the first.
it("keeps names unique when a title's extension leaves no room for the ID", async () => {
const outDir = join(root, "backup");
const shared = `x.${"Q".repeat(245)} (2)`;
const lib = libraryOf([
{
collection: collection(10, "Trip"),
files: [
file(1, 10, shared),
file(2, 10, `x (1).${"Q".repeat(250)}-`),
file(3, 10, shared),
],
},
]);
const result = await runBackup(lib, { downloadDirectory: outDir });
expect(result.failed).toBe(0);
expect(tree(outDir)).toEqual([
"Trip/",
`Trip/ (2).${"Q".repeat(250)} -> ${linkTo("2.bin")}`,
`Trip/x (1).${"Q".repeat(245)} (2) -> ${linkTo("1.bin")}`,
`Trip/x (3).${"Q".repeat(245)} (2) -> ${linkTo("3.bin")}`,
"Trip.json",
]);
});
// A title of 255 bytes is the longest file name allowed, and an album
// name of 250 bytes the longest whose JSON name fits; both are kept as
// they are. A name one byte longer gets the ID and is cut short. "é"
// takes two bytes, and in both names the room left ends inside one, so
// the cut falls before it, one byte short of the room.
it("keeps a name at the byte limit and adds the ID to one a byte over it", async () => {
const outDir = join(root, "backup");
const album = "A".repeat(250);
const title = `${"t".repeat(251)}.jpg`;
const longAlbum = `${"é".repeat(125)}B`;
const longTitle = `${"é".repeat(126)}.jpg`;
const lib = libraryOf([
{
collection: collection(10, album),
files: [file(1, 10, title), file(2, 10, longTitle)],
},
{
collection: collection(11, longAlbum),
files: [file(3, 11, "c.jpg")],
},
]);
const result = await runBackup(lib, { downloadDirectory: outDir });
const folder = `${"é".repeat(122)} (11)`;
const link = `${"é".repeat(123)} (2).jpg`;
expect(Buffer.byteLength(`${album}.json`)).toBe(255);
expect(Buffer.byteLength(title)).toBe(255);
expect(Buffer.byteLength(longAlbum)).toBe(251);
expect(Buffer.byteLength(longTitle)).toBe(256);
expect(Buffer.byteLength(`${folder}.json`)).toBe(254);
expect(Buffer.byteLength(link)).toBe(254);
expect(result.failed).toBe(0);
expect(result.failedAlbums).toEqual([]);
expect(tree(outDir)).toEqual([
`${album}/`,
`${album}/${title} -> ${linkTo("1.jpg")}`,
`${album}/${link} -> ${linkTo("2.jpg")}`,
`${album}.json`,
`${folder}/`,
`${folder}/c.jpg -> ${linkTo("3.jpg")}`,
`${folder}.json`,
]);
expect(albumID(outDir, `${album}.json`)).toBe(10);
expect(albumID(outDir, `${folder}.json`)).toBe(11);
});
it("changes nothing on a second run over an unchanged account", async () => { it("changes nothing on a second run over an unchanged account", async () => {
const outDir = join(root, "backup"); const outDir = join(root, "backup");
const lib = libraryOf([ const lib = libraryOf([
@@ -2302,12 +1973,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 +1995,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();
}); });
+11 -127
View File
@@ -512,6 +512,11 @@ describe("files", () => {
expect(await filesCommand(context(), { collection: "9" })).toBe(1); expect(await filesCommand(context(), { collection: "9" })).toBe(1);
expect(stderr.text).toBe("Collection 9 not found\n"); expect(stderr.text).toBe("Collection 9 not found\n");
}); });
it("exits 1 for a collection ID that is not a number", async () => {
expect(await filesCommand(context(), { collection: "abc" })).toBe(1);
expect(stderr.text).toBe("Invalid collection ID\n");
});
}); });
describe("get and get-thumb", () => { describe("get and get-thumb", () => {
@@ -542,6 +547,12 @@ describe("get and get-thumb", () => {
expect(stderr.text).toBe("File 999 not found\n"); expect(stderr.text).toBe("File 999 not found\n");
expect(existsSync(out)).toBe(false); expect(existsSync(out)).toBe(false);
}); });
it("both exit 1 for a file ID that is not a number", async () => {
expect(await getCommand(context(), "abc", {})).toBe(1);
expect(await getThumbCommand(context(), "abc", {})).toBe(1);
expect(stderr.text).toBe("Invalid file ID\nInvalid file ID\n");
});
}); });
// A live photo, which Ente stores as one ZIP, is written as its image and its // A live photo, which Ente stores as one ZIP, is written as its image and its
@@ -1165,130 +1176,3 @@ describe("backup-metadata and the thumbnail helpers refresh first", () => {
expect(stdout.text).toBe(""); expect(stdout.text).toBe("");
}); });
}); });
// `files --collection`, `get`, `get-thumb` and `helper fix-missing-thumbnails
// --file` take an ID only when it is written in decimal digits and is a safe
// integer. `Number()` alone reads `0x10` as 16, `1e3` as 1000 and an empty
// string as 0, so the command would act on a file the user never named.
// Anything else is refused with one line naming it, and exit 1, before any
// request.
//
// These run on a real client, read back from a saved session, over a fake Ente
// server holding an empty account, which records each request it receives. With
// a good ID the command goes on to ask the server, and finds nothing there.
describe("an ID given on the command line", () => {
const NOT_IDS = [
"0x10",
"1e3",
"1.5",
"-1",
"",
"abc",
// One past Number.MAX_SAFE_INTEGER: from here on a number cannot
// hold every integer, so two IDs could read as one.
"9007199254740992",
];
const requests: Request[] = [];
const emptyAccount = (): CliContext => {
const ctx = context();
const key = toBase64(new Uint8Array(32));
saveSession(ctx.sessionDir, {
email: "cli@example.com",
userID: USER_ID,
token: "token",
masterKey: key,
secretKey: key,
publicKey: key,
});
return {
...ctx,
loadSession: (path) =>
loadSession(path, {
fetch: async (url, init) => {
requests.push(new Request(url, init));
return new Response(
JSON.stringify({ collections: [] }),
{ headers: { "content-type": "application/json" } },
);
},
}),
};
};
beforeEach(() => {
requests.length = 0;
});
it("files looks up a decimal collection ID", async () => {
const ctx = emptyAccount();
expect(await filesCommand(ctx, { collection: "1" })).toBe(1);
expect(stderr.text).toBe("Collection 1 not found\n");
expect(requests).not.toHaveLength(0);
});
it.each(NOT_IDS)(
"files refuses --collection %j before any request",
async (arg) => {
const ctx = emptyAccount();
expect(await filesCommand(ctx, { collection: arg })).toBe(1);
expect(stderr.text).toBe(`Invalid collection ID: ${arg}\n`);
expect(stdout.text).toBe("");
expect(requests).toHaveLength(0);
},
);
it("get looks up a decimal file ID", async () => {
expect(await getCommand(emptyAccount(), "100", {})).toBe(1);
expect(stderr.text).toBe("File 100 not found\n");
expect(requests).not.toHaveLength(0);
});
it.each(NOT_IDS)("get refuses %j before any request", async (arg) => {
expect(await getCommand(emptyAccount(), arg, {})).toBe(1);
expect(stderr.text).toBe(`Invalid file ID: ${arg}\n`);
expect(stdout.text).toBe("");
expect(requests).toHaveLength(0);
});
it("get-thumb looks up a decimal file ID", async () => {
expect(await getThumbCommand(emptyAccount(), "100", {})).toBe(1);
expect(stderr.text).toBe("File 100 not found\n");
expect(requests).not.toHaveLength(0);
});
it.each(NOT_IDS)("get-thumb refuses %j before any request", async (arg) => {
expect(await getThumbCommand(emptyAccount(), arg, {})).toBe(1);
expect(stderr.text).toBe(`Invalid file ID: ${arg}\n`);
expect(stdout.text).toBe("");
expect(requests).toHaveLength(0);
});
// The fixer reports each file it was given; on this account neither is
// found, which is a failure, so it exits 1.
it("fix-missing-thumbnails looks up each decimal --file ID", async () => {
const ctx = emptyAccount();
const opts = { file: ["100", "200"], json: true };
expect(await fixMissingThumbnailsCommand(ctx, opts)).toBe(1);
expect(JSON.parse(stdout.text)).toMatchObject([
{ fileID: 100, reason: "file not found in any collection" },
{ fileID: 200, reason: "file not found in any collection" },
]);
expect(requests).not.toHaveLength(0);
});
// One bad ID fails the whole command, so the good one before it is not
// fixed either.
it.each(NOT_IDS)(
"fix-missing-thumbnails refuses --file 100 %j before any request",
async (arg) => {
const ctx = emptyAccount();
const opts = { file: ["100", arg] };
expect(await fixMissingThumbnailsCommand(ctx, opts)).toBe(1);
expect(stderr.text).toBe(`Invalid file ID: ${arg}\n`);
expect(stdout.text).toBe("");
expect(requests).toHaveLength(0);
},
);
});
+179 -65
View File
@@ -37,8 +37,15 @@ import {
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import sodium from "libsodium-wrappers-sumo"; import sodium from "libsodium-wrappers-sumo";
import { SRP, SrpServer } from "fast-srp-hap";
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest"; import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { init, toBase64, encryptBlob } from "../../src/crypto/index.js"; import {
init,
toBase64,
deriveKEK,
deriveLoginSubkey,
encryptBlob,
} from "../../src/crypto/index.js";
import * as jpegJs from "jpeg-js"; import * as jpegJs from "jpeg-js";
import { Client } from "../../src/client.js"; import { Client } from "../../src/client.js";
import { Library } from "../../src/library/index.js"; import { Library } from "../../src/library/index.js";
@@ -47,13 +54,7 @@ import {
type MetadataBackupOptions, type MetadataBackupOptions,
} from "../../src/metadata-backup.js"; } from "../../src/metadata-backup.js";
import { backupMetadataCommand } from "../../src/cli-commands.js"; import { backupMetadataCommand } from "../../src/cli-commands.js";
import { import type { KeyAttributes } from "../../src/auth/types.js";
buildAccount,
buildMetaFetch,
encryptSecretbox,
encryptStreamBlob,
type MetaMockState,
} from "../ente-server.js";
// One file per ML data request, so the two files of the mock account are // One file per ML data request, so the two files of the mock account are
// fetched in two requests and one of them can fail on its own. // fetched in two requests and one of them can fail on its own.
@@ -64,13 +65,80 @@ vi.mock("../../src/mldata-fetch.js", async (importOriginal) => ({
const TEST_EMAIL = "metabackup@example.com"; const TEST_EMAIL = "metabackup@example.com";
const TEST_PASSWORD = "metapass"; 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 mock: MetaMockState;
let testDir: string; 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 buildMetaMock = async (): Promise<MetaMockState> => {
const account = await buildAccount(TEST_PASSWORD); const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
const masterKey = account.masterKey; 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 // Collection 1: "Vacation" with collection-level pubMagicMetadata
const ck1 = sodium.crypto_secretbox_keygen(); const ck1 = sodium.crypto_secretbox_keygen();
@@ -264,10 +332,17 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
rawFile1.file.decryptionHeader = toBase64(filePush1.header); rawFile1.file.decryptionHeader = toBase64(filePush1.header);
return { return {
verifier: account.verifier, verifier,
srpAttributes: account.srpAttributes, srpAttributes: {
keyAttributes: account.keyAttributes, srpUserID,
encryptedToken: account.encryptedToken, srpSalt: toBase64(srpSalt),
memLimit: TEST_MEM,
opsLimit: TEST_OPS,
kekSalt: toBase64(kekSalt),
isEmailMFAEnabled: false,
},
keyAttributes,
encryptedToken: toBase64(encToken),
collections: [rawColl1, rawColl2], collections: [rawColl1, rawColl2],
filesByCollection: { 10: [rawFile1], 20: [rawFile2] }, filesByCollection: { 10: [rawFile1], 20: [rawFile2] },
encryptedMLData: { encryptedMLData: {
@@ -280,6 +355,90 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
}; };
}; };
// `failMLDataFor`: answer 500 to every ML data request that asks for this file.
const buildMetaFetch = (m: MetaMockState, failMLDataFor?: number) => {
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);
if ((body.fileIDs as number[]).includes(failMLDataFor!))
return new Response("server error", { status: 500 });
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 () => { beforeAll(async () => {
await init(); await init();
await sodium.ready; await sodium.ready;
@@ -489,15 +648,15 @@ describe("quak backup-metadata", () => {
}); });
}); });
describe("quak backup-metadata when a file's ML data cannot be read", () => { describe("quak backup-metadata when an ML data request fails", () => {
// Run the CLI command against `fetch`, the mock unless given, and return // Run the CLI command against the mock and return its exit code, stderr
// its exit code, stderr and output directory. // and output directory.
const runCommand = async (fetch = buildMetaFetch(mock)) => { const runCommand = async (failMLDataFor?: number) => {
const client = await Client.login({ const client = await Client.login({
email: TEST_EMAIL, email: TEST_EMAIL,
password: TEST_PASSWORD, password: TEST_PASSWORD,
apiOptions: { apiOptions: {
fetch, fetch: buildMetaFetch(mock, failMLDataFor),
retry: { sleep: async () => {} }, retry: { sleep: async () => {} },
}, },
}); });
@@ -518,9 +677,7 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
}; };
it("writes every file, marks the failed batch's files, and exits 1", async () => { it("writes every file, marks the failed batch's files, and exits 1", async () => {
const { code, stderr, outDir } = await runCommand( const { code, stderr, outDir } = await runCommand(200);
buildMetaFetch(mock, 200),
);
expect(code).toBe(1); expect(code).toBe(1);
expect(stderr).toContain("ML data request for 1 file(s) failed"); expect(stderr).toContain("ML data request for 1 file(s) failed");
@@ -545,49 +702,6 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
expect(failed.mlDataError).toContain("500"); expect(failed.mlDataError).toContain("500");
}); });
// The server sends ML data for diagram.png encrypted under a key that is
// not the file's, so quak cannot decrypt it.
it("writes the reason for ML data that does not decrypt, and exits 1", async () => {
const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { header, ciphertext } = encryptBlob(
new Uint8Array(gzipSync(Buffer.from("{}"))),
otherKey,
);
const { code, stderr, outDir } = await runCommand(
buildMetaFetch({
...mock,
encryptedMLData: {
...mock.encryptedMLData,
200: {
encryptedData: toBase64(ciphertext),
decryptionHeader: toBase64(header),
},
},
}),
);
expect(code).toBe(1);
const reason = "secretstream chunk authentication failed";
expect(stderr).toContain(
`ML data of file 200 could not be read: ${reason}\n`,
);
const failed = JSON.parse(
readFileSync(
join(outDir, "collections", "20-__Work", "200.json"),
"utf-8",
),
);
expect(failed.mlData).toBeUndefined();
expect(failed.mlDataError).toBe(reason);
const ok = JSON.parse(
readFileSync(
join(outDir, "collections", "10-Vacation", "100.json"),
"utf-8",
),
);
expect(ok.mlData.clip.embedding).toEqual([0.5, 0.6, 0.7]);
});
it("exits 0 when every ML data request succeeds", async () => { it("exits 0 when every ML data request succeeds", async () => {
const { code } = await runCommand(); const { code } = await runCommand();
expect(code).toBe(0); expect(code).toBe(0);
-80
View File
@@ -48,78 +48,6 @@ describe("run", () => {
); );
}); });
// Node's `fetch` rejects every failure below HTTP with a TypeError that
// says only "fetch failed", and hangs the reason, with its errno, off
// `cause`. The printed line carries the reason, so a cron log tells a
// refused connection from a name that did not resolve.
it("prints the reason a fetch failed, from the error's cause", async () => {
const refused = new TypeError("fetch failed", {
cause: Object.assign(
new Error("connect ECONNREFUSED 1.2.3.4:443"),
{ code: "ECONNREFUSED" },
),
});
const result = await runToExit(Promise.reject(refused));
expect(result).toEqual({
code: 1,
stdout: "",
stderr: "quak: fetch failed: connect ECONNREFUSED 1.2.3.4:443\n",
});
});
// When the host has several addresses and every one refuses, Node's
// `fetch` gives as the cause an AggregateError with an empty message and
// one error per address. The printed line gives each address's reason.
it("prints the reason for each address a fetch could not reach", async () => {
const refusedAt = (address: string): Error =>
Object.assign(new Error(`connect ECONNREFUSED ${address}`), {
code: "ECONNREFUSED",
});
const refused = new TypeError("fetch failed", {
cause: Object.assign(
new AggregateError([
refusedAt("2001:db8::1:443"),
refusedAt("1.2.3.4:443"),
]),
{ code: "ECONNREFUSED" },
),
});
const result = await runToExit(Promise.reject(refused));
expect(result).toEqual({
code: 1,
stdout: "",
stderr: "quak: fetch failed: connect ECONNREFUSED 2001:db8::1:443, connect ECONNREFUSED 1.2.3.4:443\n",
});
});
// An error that already quotes its cause's message is printed as it is.
it("does not print a cause's message twice", async () => {
const reset = new Error("read ECONNRESET");
const wrapped = new Error(`download: file 7: ${reset.message}`, {
cause: reset,
});
const result = await runToExit(Promise.reject(wrapped));
expect(result.stderr).toBe("quak: download: file 7: read ECONNRESET\n");
});
// OpenSSL ends its message for a failed TLS connection, such as one to a
// server that answers in plain HTTP, with a newline. The printed line is
// still one line.
it("prints a cause's message without the white space around it", async () => {
const wrongVersion = new TypeError("fetch failed", {
cause: Object.assign(
new Error(
"C0B73A55A47F0000:error:0A00010B:SSL routines:ssl3_get_record:wrong version number:../deps/openssl/openssl/ssl/record/ssl3_record.c:354:\n",
),
{ code: "ERR_SSL_WRONG_VERSION_NUMBER" },
),
});
const result = await runToExit(Promise.reject(wrongVersion));
expect(result.stderr).toBe(
"quak: fetch failed: C0B73A55A47F0000:error:0A00010B:SSL routines:ssl3_get_record:wrong version number:../deps/openssl/openssl/ssl/record/ssl3_record.c:354:\n",
);
});
it("prints a thrown value that is not an Error", async () => { it("prints a thrown value that is not an Error", async () => {
const result = await runToExit(Promise.reject("offline")); const result = await runToExit(Promise.reject("offline"));
expect(result).toEqual({ expect(result).toEqual({
@@ -129,14 +57,6 @@ describe("run", () => {
}); });
}); });
// A cause that is not an Error, such as a thrown string, is printed as a
// thrown value that is not an Error is.
it("prints a cause that is not an Error", async () => {
const wrapped = new Error("ML data", { cause: "offline" });
const result = await runToExit(Promise.reject(wrapped));
expect(result.stderr).toBe("quak: ML data: offline\n");
});
it("on a 401 from the server says to run quak login, on one line, and exits 3", async () => { it("on a 401 from the server says to run quak login, on one line, and exits 3", async () => {
const result = await runToExit( const result = await runToExit(
Promise.reject(new ApiError("unauthorized", 401)), Promise.reject(new ApiError("unauthorized", 401)),
+10 -126
View File
@@ -10,9 +10,7 @@
* The return shape keeps live records and tombstones apart — `collections` / * The return shape keeps live records and tombstones apart — `collections` /
* `files` are decrypted live records, `deleted` is a plain list of the ids the * `files` are decrypted live records, `deleted` is a plain list of the ids the
* server tombstoned. A tombstone carries no decryptable key or metadata, so it * server tombstoned. A tombstone carries no decryptable key or metadata, so it
* is a bare id rather than a hollowed-out `Collection` / `EnteFile`. A live row * is a bare id rather than a hollowed-out `Collection` / `EnteFile`.
* that does not decrypt, or whose metadata is not JSON, is listed under
* `undecryptable` with the reason, so one bad row does not fail the call.
* *
* All tests inject a fake `fetch` and drive a real `Client` (built with * All tests inject a fake `fetch` and drive a real `Client` (built with
* `Client.fromJSON`) so the decryption path runs for real. Live rows are built * `Client.fromJSON`) so the decryption path runs for real. Live rows are built
@@ -93,38 +91,27 @@ const ownedCollectionRow = (
}; };
}; };
/** /** A live file row inside a collection, keyed under that collection's key. */
* A live file row inside a collection, keyed under that collection's key. Its
* metadata decrypts to `metadata` when given, otherwise to the JSON a client
* uploads for `title`.
*/
const fileRow = ( const fileRow = (
collectionKey: Uint8Array, collectionKey: Uint8Array,
opts: { opts: { id: number; title: string; updationTime: number },
id: number;
title: string;
updationTime: number;
metadata?: string;
},
): Record<string, unknown> => { ): Record<string, unknown> => {
const fileKey = sodium.crypto_secretbox_keygen(); const fileKey = sodium.crypto_secretbox_keygen();
const { ciphertext: encFileKey, nonce: fileKeyNonce } = secretboxEncrypt( const { ciphertext: encFileKey, nonce: fileKeyNonce } = secretboxEncrypt(
fileKey, fileKey,
collectionKey, collectionKey,
); );
const metadata = const metadata = {
opts.metadata ?? title: opts.title,
JSON.stringify({ fileType: 0,
title: opts.title, creationTime: opts.updationTime,
fileType: 0, modificationTime: opts.updationTime,
creationTime: opts.updationTime, };
modificationTime: opts.updationTime,
});
const push = const push =
sodium.crypto_secretstream_xchacha20poly1305_init_push(fileKey); sodium.crypto_secretstream_xchacha20poly1305_init_push(fileKey);
const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push( const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push(
push.state, push.state,
new TextEncoder().encode(metadata), new TextEncoder().encode(JSON.stringify(metadata)),
null, null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
); );
@@ -144,21 +131,6 @@ const fileRow = (
}; };
}; };
/**
* `row` with its sealed key replaced by random bytes of the same length, as a
* corrupt row arrives: no key opens it, so the row cannot be decrypted.
*/
const withCorruptKey = (
row: Record<string, unknown>,
): Record<string, unknown> => ({
...row,
encryptedKey: toBase64(
sodium.randombytes_buf(
sodium.crypto_secretbox_KEYBYTES + sodium.crypto_secretbox_MACBYTES,
),
),
});
/** A tombstone row. Never decrypted, so only these fields are ever read. */ /** A tombstone row. Never decrypted, so only these fields are ever read. */
const tombstoneRow = ( const tombstoneRow = (
id: number, id: number,
@@ -306,58 +278,6 @@ describe("Client.filesSince", () => {
// It gave up after the first page rather than looping. // It gave up after the first page rather than looping.
expect(sinceTimes).toEqual(["50"]); expect(sinceTimes).toEqual(["50"]);
}); });
it("leaves out each file it cannot decrypt or parse, with the reason, and decrypts the rest", async () => {
const keys = buildKeys();
const collectionKey = sodium.crypto_secretbox_keygen();
// File 1002's key is sealed under no key quak holds, and file 1003's
// metadata decrypts to text that is not JSON. Neither may cost the
// caller file 1001: each is left out of `files` and reported under
// `undecryptable` with the reason instead of failing the call.
const { fetch } = recordingFetch(
jsonResponse({
diff: [
fileRow(collectionKey, {
id: 1001,
title: "fine.jpg",
updationTime: 100,
}),
withCorruptKey(
fileRow(collectionKey, {
id: 1002,
title: "bad-key.jpg",
updationTime: 200,
}),
),
fileRow(collectionKey, {
id: 1003,
title: "bad-metadata.jpg",
updationTime: 300,
metadata: "not json",
}),
],
hasMore: false,
}),
);
const client = Client.fromJSON(snapshotFor(keys), { fetch });
const { files, undecryptable, cursor } = await client.filesSince({
collectionID: 1,
collectionKey,
sinceTime: 0,
});
expect(files.map((f) => f.id)).toEqual([1001]);
expect(undecryptable).toEqual([
{ id: 1002, error: expect.stringContaining("wrong secret key") },
{ id: 1003, error: expect.stringContaining("JSON") },
]);
// The cursor moves past the two left out, as past any other row, so
// the server does not send them again until its copy changes. The
// caller has to keep what it was told here.
expect(cursor).toBe(300);
});
}); });
describe("Client.collectionsSince", () => { describe("Client.collectionsSince", () => {
@@ -411,42 +331,6 @@ describe("Client.collectionsSince", () => {
expect(result.cursor).toBe(150); expect(result.cursor).toBe(150);
expect(sinceTimes).toEqual(["150"]); expect(sinceTimes).toEqual(["150"]);
}); });
it("leaves out an album it cannot decrypt, with the reason, and decrypts the rest", async () => {
const keys = buildKeys();
// Album 2's key is sealed under no key quak holds. Without its key
// neither its name nor its files can be read, so it is left out and
// reported under `undecryptable`; album 1 is unaffected.
const { fetch } = recordingFetch(
jsonResponse({
collections: [
ownedCollectionRow(keys.masterKey, {
id: 1,
name: "Vacation",
updationTime: 100,
}),
withCorruptKey(
ownedCollectionRow(keys.masterKey, {
id: 2,
name: "Broken",
updationTime: 200,
}),
),
],
}),
);
const client = Client.fromJSON(snapshotFor(keys), { fetch });
const { collections, undecryptable, cursor } =
await client.collectionsSince({ sinceTime: 0 });
expect(collections.map((c) => c.name)).toEqual(["Vacation"]);
expect(undecryptable).toEqual([
{ id: 2, error: expect.stringContaining("wrong secret key") },
]);
expect(cursor).toBe(200);
});
}); });
describe("Client list wrappers still hide deletions", () => { describe("Client list wrappers still hide deletions", () => {
+1 -10
View File
@@ -74,11 +74,7 @@ import {
} from "vitest"; } from "vitest";
import { init, toBase64, STREAM_CHUNK_SIZE } from "../../src/crypto/index.js"; import { init, toBase64, STREAM_CHUNK_SIZE } from "../../src/crypto/index.js";
import { ApiClient } from "../../src/api/client.js"; import { ApiClient } from "../../src/api/client.js";
import { import { ApiError, TruncatedStreamError } from "../../src/errors.js";
ApiError,
describeError,
TruncatedStreamError,
} from "../../src/errors.js";
import type { RetryOptions } from "../../src/retry.js"; import type { RetryOptions } from "../../src/retry.js";
import { import {
downloadFile, downloadFile,
@@ -838,11 +834,6 @@ describe.each(entryPoints)(
expect(((err as Error).cause as Error).message).toMatch( expect(((err as Error).cause as Error).message).toMatch(
/authentication failed/i, /authentication failed/i,
); );
// As quak prints it and records it in `failures.json`, the error
// states the authentication failure once, from its cause.
expect(describeError(err)).toMatch(
/^download: stream truncated: response body ended with \d+ trailing bytes \(transfer stopped mid-chunk, or the data is corrupt\): secretstream chunk authentication failed$/,
);
// And, as with every other failure, the destination is untouched // And, as with every other failure, the destination is untouched
// and no staged temp file survives. // and no staged temp file survives.
-197
View File
@@ -1,197 +0,0 @@
/**
* A fake of the Ente HTTP API, for tests that run quak's real `Client` against
* a server.
*
* `buildMetaFetch` is a `fetch` that answers the requests quak makes, from a
* `MetaMockState` the test builds: the SRP login, `/collections/v2`, each
* collection's `/collections/v2/diff`, the ML data of `/files/data/fetch`, and
* each file's content from the files CDN, a 404 for a file it holds none for.
* The test encrypts everything in that state with libsodium, as Ente's clients
* do, so quak decrypts it with its own code. Nothing hits the network.
*
* `buildAccount` builds what the server holds to log an account in, and the
* keys a test encrypts that account's collections with. Call `init()` from
* `src/crypto` and await `sodium.ready` before using either.
*/
import sodium from "libsodium-wrappers-sumo";
import { SRP, SrpServer } from "fast-srp-hap";
import { toBase64, deriveKEK, deriveLoginSubkey } from "../src/crypto/index.js";
import type { KeyAttributes } from "../src/auth/types.js";
const TEST_OPS = 2;
const TEST_MEM = 64 * 1024 * 1024;
export 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>;
}
export 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 };
};
export 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 };
};
// An account whose password is `password`. The first four fields are those of
// `MetaMockState`; with the master key the test encrypts the keys of the
// account's own collections, and to the public key it seals the keys of the
// collections other accounts share with it.
export const buildAccount = async (password: string) => {
const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
const kek = await deriveKEK(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,
};
return {
verifier,
srpAttributes: {
srpUserID,
srpSalt: toBase64(srpSalt),
memLimit: TEST_MEM,
opsLimit: TEST_OPS,
kekSalt: toBase64(kekSalt),
isEmailMFAEnabled: false,
},
keyAttributes,
encryptedToken: toBase64(encToken),
masterKey,
publicKey: kp.publicKey,
};
};
// `failMLDataFor`: answer 500 to every ML data request that asks for this file.
export const buildMetaFetch = (m: MetaMockState, failMLDataFor?: number) => {
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);
if ((body.fileIDs as number[]).includes(failMLDataFor!))
return new Response("server error", { status: 500 });
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;
};
+1 -28
View File
@@ -14,11 +14,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import { safeExtension, sanitizeFileName } from "../../src/filename.js";
safeExtension,
sanitizeFileName,
shortenToBytes,
} from "../../src/filename.js";
const FALLBACK = "file-42"; const FALLBACK = "file-42";
@@ -95,26 +91,3 @@ describe("safeExtension", () => {
expect(safeExtension("x.jpg\0")).toBe(".bin"); expect(safeExtension("x.jpg\0")).toBe(".bin");
}); });
}); });
describe("shortenToBytes", () => {
it("returns a name that fits unchanged", () => {
expect(shortenToBytes("beach", 5)).toBe("beach");
expect(shortenToBytes("café", 5)).toBe("café");
expect(shortenToBytes("", 0)).toBe("");
});
it("cuts a name to the number of bytes given", () => {
expect(shortenToBytes("L".repeat(300), 255)).toBe("L".repeat(255));
});
// In UTF-8 "é" takes two bytes and an emoji four, which a JavaScript
// string holds as two halves.
it("cuts between two characters, never inside one", () => {
expect(shortenToBytes("ééé", 5)).toBe("éé");
expect(shortenToBytes("a\u{1F600}b", 4)).toBe("a");
expect(shortenToBytes("a\u{1F600}b", 5)).toBe("a\u{1F600}");
expect(Buffer.byteLength(shortenToBytes("é".repeat(200), 255))).toBe(
254,
);
});
});
+9 -150
View File
@@ -12,11 +12,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.
* file whose ML data did not decode is reported with the reason, which * Opening a cache directory written by another account starts empty,
* stays listed until the next pass ends, and asked for again. Opening a * its ML data included (issue #104).
* cache directory written by another account starts empty, its ML data
* 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.
@@ -28,7 +26,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { MLDataStore } from "../../src/library/mldata.js"; import { MLDataStore } from "../../src/library/mldata.js";
import { Library, type RefreshEvent } from "../../src/library/index.js"; import { Library } from "../../src/library/index.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js";
import type { MLData } from "../../src/mldata-fetch.js"; import type { MLData } from "../../src/mldata-fetch.js";
import type { Collection, EnteFile } from "../../src/model/types.js"; import type { Collection, EnteFile } from "../../src/model/types.js";
@@ -258,14 +256,12 @@ const file = (
// A mock client that serves scripted collection/file pages and per-file ML // A mock client that serves scripted collection/file pages and per-file ML
// payloads, recording every ML fetch request so incremental behaviour is // payloads, recording every ML fetch request so incremental behaviour is
// provable. A file in `mlErrorByFile` has ML data that did not decode, for // provable.
// the reason given there.
class MLMockClient { class MLMockClient {
userID = USER_ID; userID = USER_ID;
collectionsQueue: CollectionsPage[] = []; collectionsQueue: CollectionsPage[] = [];
filesByCollection = new Map<number, FilesPage[]>(); filesByCollection = new Map<number, FilesPage[]>();
mlByFile = new Map<number, MLData>(); mlByFile = new Map<number, MLData>();
mlErrorByFile = new Map<number, Error>();
mlFetchCalls: number[][] = []; mlFetchCalls: number[][] = [];
whoami(): { email: string; userID: number } { whoami(): { email: string; userID: number } {
@@ -302,20 +298,14 @@ class MLMockClient {
async fetchMLData(args: { async fetchMLData(args: {
fileIDs: number[]; fileIDs: number[];
fileKeys: Map<number, Uint8Array>; fileKeys: Map<number, Uint8Array>;
}): Promise<{ }): Promise<Map<number, MLData>> {
payloads: Map<number, MLData>;
errors: Map<number, unknown>;
}> {
this.mlFetchCalls.push([...args.fileIDs]); this.mlFetchCalls.push([...args.fileIDs]);
const payloads = new Map<number, MLData>(); const result = new Map<number, MLData>();
const errors = new Map<number, unknown>();
for (const id of args.fileIDs) { for (const id of args.fileIDs) {
const p = this.mlByFile.get(id); const p = this.mlByFile.get(id);
if (p) payloads.set(id, p); if (p) result.set(id, p);
const error = this.mlErrorByFile.get(id);
if (error) errors.set(id, error);
} }
return { payloads, errors }; return result;
} }
filesFor(collectionID: number, ...pages: FilesPage[]): void { filesFor(collectionID: number, ...pages: FilesPage[]): void {
@@ -459,137 +449,6 @@ describe("Library ML-data fetch on refresh", () => {
} }
}); });
it("reports a file whose ML data did not decode through status() and onProgress, and asks for it again", async () => {
const client = new MLMockClient();
client.collectionsQueue.push({
collections: [collection(1, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(1, {
files: [file(1001, 1, 90), file(1002, 1, 95)],
deleted: [],
cursor: 95,
});
client.mlByFile.set(1001, payload([0.5, 0.25, 0.75]));
client.mlErrorByFile.set(
1002,
new Error("secretstream chunk authentication failed"),
);
const events: RefreshEvent[] = [];
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: Infinity,
onProgress: (event) => events.push(event),
});
try {
await vi.waitFor(
() => expect(lib.status().lastMLFetchAt).toBeGreaterThan(0),
{ timeout: 2000, interval: 5 },
);
const reasons = [
{
fileID: 1002,
error: "secretstream chunk authentication failed",
},
];
expect(lib.status().mlDataErrors).toEqual(reasons);
expect(lib.status().mlStored).toBe(1);
expect(events.filter((e) => e.operation === "fetchMLData")).toEqual(
[
{ operation: "fetchMLData", status: "started" },
{
operation: "fetchMLData",
status: "done",
fetched: 1,
mlDataErrors: reasons,
},
],
);
// Nothing was cached for 1002, so the pass a refresh starts asks
// for it again, and once its ML data decodes the reason is gone.
client.mlErrorByFile.clear();
client.mlByFile.set(1002, payload([1, -2, 0.5]));
await lib.fresh();
await vi.waitFor(
() => {
expect(lib.status().mlStored).toBe(2);
expect(lib.status().mlDataErrors).toEqual([]);
},
{ timeout: 2000, interval: 5 },
);
expect(client.mlFetchCalls.at(-1)).toEqual([1002]);
} finally {
await lib.close();
}
});
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({
-23
View File
@@ -172,29 +172,6 @@ describe("MetadataStore", () => {
expect(store.listCollections()).toEqual([]); expect(store.listCollections()).toEqual([]);
}); });
it("loads an empty store when the list of what the refresh could not decrypt is damaged", async () => {
// A backup reads that list on every run, so a damaged one that loaded
// would fail every backup until the cache was deleted. A string is
// damaged too, though JavaScript can loop over its characters.
mkdirSync(join(dir, "cache"), { recursive: true });
for (const undecryptable of [{}, "not a list", [1]]) {
writeFileSync(
path,
JSON.stringify({
schemaVersion: METADATA_SCHEMA_VERSION,
userID: 42,
collectionsSinceTime: 0,
collections: [],
files: [],
undecryptable,
}),
);
const store = await MetadataStore.load(path);
expect(store.userID).toBe(0);
expect(store.listUndecryptable()).toEqual([]);
}
});
it("creates the directory 0700 and the file 0600", async () => { it("creates the directory 0700 and the file 0600", async () => {
const store = await MetadataStore.load(path); const store = await MetadataStore.load(path);
store.putCollection(sampleCollection()); store.putCollection(sampleCollection());
-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"));
},
);
});
-84
View File
@@ -77,14 +77,6 @@ const recordingSleep = (): {
const errnoError = (code: string, message = code): Error => const errnoError = (code: string, message = code): Error =>
Object.assign(new Error(message), { code }); Object.assign(new Error(message), { code });
/**
* An error shaped like what bun's `fetch` rejects with for a failure below
* HTTP: a plain `Error` with bun's own message, a `code`, an `errno` of 0, and
* no `cause`.
*/
const bunError = (code: string, message: string): Error =>
Object.assign(new Error(message), { code, errno: 0 });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Classification // Classification
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -252,82 +244,6 @@ describe("isRetryable: transport failures", () => {
}); });
}); });
describe("isRetryable and isSafeToReplay: bun's fetch errors", () => {
// The binary `make install` builds runs quak under bun, whose `fetch`
// does not reject as Node's does. Each shape here was found by running
// that binary, built with bun 1.3.13, against listeners on 127.0.0.1 and
// a name under `.invalid`.
it("retries a refused connection, and does not replay it", () => {
// Bun gives the same error when the TLS handshake fails, and when a
// new connection it opened to resend a request is refused. That
// request may already have reached the server on a connection bun
// kept open from an earlier request.
const refused = bunError(
"ConnectionRefused",
"Unable to connect. Is the computer able to access the url?",
);
expect(isRetryable(refused)).toBe(true);
expect(isSafeToReplay(refused)).toBe(false);
});
it("retries a name that does not resolve, with either code bun gives, and does not replay it", () => {
// For the same name bun gave `ConnectionRefused` on some requests and
// `FailedToOpenSocket` on others. Either can also come from a new
// connection bun opened to resend a request, as above.
for (const unresolved of [
bunError(
"ConnectionRefused",
"Unable to connect. Is the computer able to access the url?",
),
bunError(
"FailedToOpenSocket",
"Was there a typo in the url or port?",
),
]) {
expect(isRetryable(unresolved)).toBe(true);
expect(isSafeToReplay(unresolved)).toBe(false);
}
});
it("retries a connection closed before the response ended, and does not replay it", () => {
// Bun gives Node's errno, `ECONNRESET`, for a reset and for a close,
// before the response or part-way through the body. The request may
// already have reached the server.
const reset = bunError(
"ECONNRESET",
"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()",
);
expect(isRetryable(reset)).toBe(true);
expect(isSafeToReplay(reset)).toBe(false);
});
it("retries a request that ran out of time, and does not replay it", () => {
// What bun's fetch rejects with when `AbortSignal.timeout()`, which
// bounds each JSON request, fires.
const timedOut = new DOMException(
"The operation timed out.",
"TimeoutError",
);
expect(isRetryable(timedOut)).toBe(true);
expect(isSafeToReplay(timedOut)).toBe(false);
});
it("does not retry a certificate bun rejects", () => {
// A certificate that fails verification fails the same way however
// often it is asked. Under Node it is retried only because every
// `TypeError` is.
expect(
isRetryable(
bunError(
"DEPTH_ZERO_SELF_SIGNED_CERT",
"self signed certificate",
),
),
).toBe(false);
});
});
describe("isRetryable: stream truncation versus corruption", () => { describe("isRetryable: stream truncation versus corruption", () => {
it("retries a truncated stream", () => { it("retries a truncated stream", () => {
// Truncation is a transfer that stopped early. The bytes that did // Truncation is a transfer that stopped early. The bytes that did