8 Commits
Author SHA1 Message Date
clawbot 68d22b9927 -v raises the CLI's log level per repeat; -q prints only fatal errors (closes #212)
check / check (push) Waiting to run
Every line quak prints on stderr now goes through one log, in src/cli-log.ts, at one of six levels. The default, info, prints what quak printed before. -v adds each retry and the start and end of each refresh and ML data fetch; -vv adds each request with its result. -q prints only fatal lines, and is refused with -v; both are program options. Every non-zero exit logs one fatal line, so a run that exits 1 because files failed says how many and where to look. Every line reporting a failure is at error. --json changes stdout only, so backup and --json runs log their progress on stderr. withRetry and ApiClient report retries and requests through new callbacks.

Model: opus-5-5
2026-10-18 21:46:59 +02:00
clawbot 92e0c6c791 quak files takes the collection ID as an optional argument (closes #211)
check / check (push) Waiting to run
Mandatory arguments are positional, so `quak files` takes the collection ID as an optional argument and the `--collection` option is removed. With a collection ID it lists that collection's files. Without one it lists every file in the account, collection by collection, each file once: the library keeps a record of a file for each collection that holds it, and only the first collection's record is listed, so that collection is its `collectionID` in `--json`. The `--help` text, the README and `TODO.md` say so.

Model: opus-5-5
2026-10-18 20:17:10 +02:00
clawbot 66e3ac77e8 Names quak builds from server data fit in 255 bytes (closes #205)
check / check (push) Waiting to run
The default names of `quak get`, `quak get-thumb`, `downloadFile` and `downloadThumbnail`, and the album folders of `backup-metadata`, could be over 255 bytes, which a file system refuses. `defaultFileName` and `shortenKeepingExtension` in `src/filename.ts` now cut such a name short, keeping its extension when it fits; a live photo's image and video get their own extension before the cut. A cut name never starts with a dot and is never a Windows device name. `--out` and `outPath` are kept as given. A `backup-metadata` folder keeps its `<id>-`. `safeExtension` gives `.bin` for an extension of more than 16 letters and digits, so a save path's name always fits.

Model: opus-5-5
2026-10-18 20:01:34 +02:00
clawbot 982b878d69 quak collections reads the collection list alone (closes #210)
check / check (push) Waiting to run
On an empty cache the read commands opened their library with a refresh that fetched and decrypted the files of every collection in turn, printing nothing and storing nothing until it had them all, so `quak collections` seemed to hang on a large account. The new `lib.freshCollections()` is the fresh read of the collection list alone: one request, and nothing stored, so a later refresh still fetches every collection's files. The read commands open their library with the new `refreshOnOpen: false` and make only the refresh they ask for. A refresh sends a "progress" event after each collection whose files it fetched; the commands that wait on one print it on stderr, except with `--json`.

Model: opus-5-5
2026-10-18 19:01:34 +02:00
clawbot 7ec71bf7ec ML data that does not decrypt is recorded with its reason (closes #198)
check / check (push) Canceled after 0s
`fetchMLDataBatch` dropped an entry that did not decrypt, gunzip or parse,
so the file looked like one with no ML data and the run exited 0. It now
returns the reason beside the payloads, also for ML data that is not a
JSON object. The library gives the reasons from its last ML data fetch
that succeeded in `status().mlDataErrors` and the `fetchMLData` "done"
event; a fetch that fails leaves them as they were. `quak backup` writes
the reason as `mlDataError` and records the file in `failures.json`;
`backup-metadata` writes `mlDataError` and exits 1.

Judgement call: a file whose ML data is cached keeps it in its JSON when a
newer copy does not decode, as when the fetch fails.

Model: opus-5-5
2026-10-16 07:23:16 +02:00
clawbot 8583863ec0 --verify keeps a mismatched original until a matching copy replaces it (closes #199)
check / check (push) Canceled after 0s
With --verify, an original whose hash does not match is no longer removed
before its replacement is downloaded. replaceOriginal, beside placeOriginal
in src/library/content.ts, has the content cache's new fetchOriginal write
the replacement to a temporary file beside the original, hashes it, and
renames it over the original only when it matches; a live photo's image and
video are both hashed before either is renamed. A failed or mismatching
download leaves the old bytes and album links, removes the temporary files,
and lands in failures.json. The replacement is always downloaded, never
copied from the cache. The leftover-file cleanup also takes a live photo's
replacement files, .quak-<pid>-<random>.<ext>.

Model: opus-5-5
2026-10-16 06:56:19 +02:00
clawbot 960a7ce8a6 An album whose folder cannot be made no longer stops the backup (closes #197)
check / check (push) Canceled after 0s
Each album's folder, links and JSON are written inside one try. A failure
prints `FAILED album <name>: <reason>`, lists the album's ID in the new
`failedAlbums` of the result and of `--json`, and makes `quak backup` exit 1;
the other albums and `failures.json` are still written.

An album whose folder would have another album's JSON name, ignoring case,
gets its ID. A name over 255 bytes (250 for a folder, which leaves room for
`.json`) gets the ID and is cut short between two characters by
`shortenToBytes` in `src/filename.ts`. A link's ID goes right before its
extension.

Apart from its call to `uniqueNames`, the album loop is only indented into
the try.

Model: opus-5-5
2026-10-16 05:23:27 +02:00
clawbot 20410f8f71 The binary make install builds retries a refused or unresolvable connection (closes #206)
check / check (push) Canceled after 0s
Bun's `fetch`, which `~/bin/quak` runs under, rejects with a plain `Error`
that has no `cause` and a code of bun's own. Running the built binary against
local listeners gave `ConnectionRefused` for a refused connection and a failed
TLS handshake, and `ConnectionRefused` or `FailedToOpenSocket` for a name that
does not resolve. Both join `TRANSPORT_CODES` in `src/retry.ts`, so they are
retried and called transient. They stay out of `CONNECT_CODES`, so a `POST` or
`PUT` is not replayed on them, because bun also gives them when it resends a
request on a new connection. `classify` in `src/backup.ts` records a
`TimeoutError` as transient, so a stalled download is no longer `unknown`.

Not changed: a certificate bun rejects is not retried.

Model: opus-5-5
2026-10-16 04:56:20 +02:00
37 changed files with 3790 additions and 583 deletions
+249 -75
View File
@@ -35,8 +35,11 @@ yarn quak login
# List the user's collections (albums). # List the user's collections (albums).
yarn quak collections yarn quak collections
# List files in a collection. # List every file in the account.
yarn quak files --collection 12345 yarn quak files
# List the files in a collection.
yarn quak files 12345
# Download and decrypt a single file. # Download and decrypt a single file.
yarn quak get 67890 --out ./photo.jpg yarn quak get 67890 --out ./photo.jpg
@@ -134,12 +137,12 @@ 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 an album it could not decrypt, 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 albums it could not decrypt or write (see "Backup layout"), and its last line says how many, 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 `<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 caused it. Under Node, as `yarn quak` runs quak, `fetch` reports every failure
@@ -151,16 +154,19 @@ 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 `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 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 `ML data: `. The `~/bin/quak` that `make install` builds runs under bun, and
prints bun's own wording for such a failure. 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 album quak could not
decrypt, or a file in `failures.json` because quak could not decrypt it, keeps decrypt, or a file in `failures.json` because quak could not decrypt it, keeps
the exit code at `1` until the server sends a copy that decrypts, or deletes it the exit code at `1` until the server sends a copy that decrypts, or deletes it
(see "Backup layout"). An original that a `--verify` run could not read, or put (see "Backup layout"). An original that a `--verify` run could not read, or
back with bytes that still do not match, stays at its save path. Only a later could not replace with a download that matches, stays at its save path as it
`--verify` run checks it again: a run without `--verify` leaves it as it is and was. Only a later `--verify` run checks it again: a run without `--verify`
takes the file out of `failures.json`, so that run can exit 0. leaves it as it is and takes the file out of `failures.json`, so that run can
exit 0.
## Examples ## Examples
@@ -372,6 +378,8 @@ quak/
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
cli-commands.ts the CLI's commands as functions returning exit codes cli-commands.ts the CLI's commands as functions returning exit codes
cli-log.ts the CLI's log level, and the one function the
commands write stderr lines through
cli-output.ts how the CLI prints a file's title and time cli-output.ts how the CLI prints a file's title and time
cli-read.ts fresh reads for the CLI's read commands cli-read.ts fresh reads for the CLI's read commands
cli-run.ts run a command, print its error, exit with its code cli-run.ts run a command, print its error, exit with its code
@@ -450,6 +458,12 @@ A custom API endpoint is configurable for self-hosted servers via the
constructor option `apiOrigin`. When set, file downloads route through constructor option `apiOrigin`. When set, file downloads route through
`<apiOrigin>/files/download/<id>` instead of the dedicated CDN host. `<apiOrigin>/files/download/<id>` instead of the dedicated CDN host.
The constructor option `onRequest` is called once for each request sent, a
retried request once for each attempt, with its method, its URL and the status
of the answer or the error the request failed with. The URL of a presigned
upload is given without its query, which holds the upload's signature. The CLI
logs each request with `-vv` (see "CLI surface").
Required request headers on every authenticated call: Required request headers on every authenticated call:
- `X-Auth-Token`: the decrypted auth token from login. - `X-Auth-Token`: the decrypted auth token from login.
@@ -494,6 +508,17 @@ 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
@@ -515,7 +540,9 @@ each wait is drawn at random below its ceiling. Every other command uses the
defaults. A library user gets the same budget by passing defaults. A library user gets the same budget by passing
`UNATTENDED_RETRY_OPTIONS` as `ApiClientOptions.retry`. `sleep` and `random` are `UNATTENDED_RETRY_OPTIONS` as `ApiClientOptions.retry`. `sleep` and `random` are
injectable through the same option, which is how the test suite exercises the injectable through the same option, which is how the test suite exercises the
whole policy without waiting. whole policy without waiting. `onRetry`, given there too, is called before each
wait with the error the attempt failed with, the attempt's number and the wait;
the CLI logs it with `-v` (see "CLI surface").
Two deadlines, renewed for each attempt: Two deadlines, renewed for each attempt:
@@ -549,9 +576,13 @@ 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. `putFile` is exempt: a presigned PUT stores retryable for the idempotent calls. So do bun's `ConnectionRefused` and
one whole object at one key in one request, so replaying it has no partial state `FailedToOpenSocket`, on which a `POST` or `PUT` is not replayed either. When a
to damage. 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. `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
@@ -625,11 +656,12 @@ thumbnails), for the user to delete if they want it gone.
``` ```
quak [--cache-dir <path>] <command> global: local metadata/content cache location quak [--cache-dir <path>] <command> global: local metadata/content cache location
quak [-v | -vv | -q] <command> global: log more on stderr, or only fatal errors
quak login interactive or QUAK_EMAIL/QUAK_PASSWORD quak login interactive or QUAK_EMAIL/QUAK_PASSWORD
quak whoami print logged-in account as JSON quak whoami print logged-in account as JSON
quak logout end the session, delete it quak logout end the session, delete it
quak collections [--json] list all collections quak collections [--json] list all collections
quak files --collection <id> [--json] list files in a collection quak files [collectionID] [--json] list files in a collection, or every file
quak get <fileID> [--out path] [--collection] download and decrypt a file quak get <fileID> [--out path] [--collection] download and decrypt a file
quak get-thumb <fileID> [--out] [--collection] download and decrypt a thumbnail quak get-thumb <fileID> [--out] [--collection] download and decrypt a thumbnail
quak backup <dir> [--json] [--verify] full incremental backup quak backup <dir> [--json] [--verify] full incremental backup
@@ -643,45 +675,124 @@ library. The read commands — `collections`, `files`, `get`, `get-thumb`,
`backup-metadata`, `helper list-missing-thumbnails` and `backup-metadata`, `helper list-missing-thumbnails` and
`helper fix-missing-thumbnails` — force a fresh server round-trip before they `helper fix-missing-thumbnails` — force a fresh server round-trip before they
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. `collections` asks the server for the collection list alone, in one
exits 1, or 3 when the server no longer accepts the saved session (see "Session request, and neither fetches any collection's files nor changes the cache. The
handling"). An album or file quak cannot decrypt does not fail it; the command others wait for a refresh. With an empty cache, as on a first run, that refresh
answers from what did decrypt (see "Default reads vs. fresh reads"). fetches the files of every collection, one collection after another, and stores
nothing until it has them all, which on a large account takes a while. Meanwhile
the command logs a line on stderr for each collection whose files it has
fetched, such as `Fetched the files of 3 of 120 collections`, with `--json` too;
`backup` does the same while the refresh it starts with runs. stdout holds only
the command's output. 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 handling"). An album or file quak cannot decrypt does not fail it;
the command answers from what did decrypt (see "Default reads vs. fresh reads").
`--cache-dir` overrides where the cache lives; without it each account gets its `--cache-dir` overrides where the cache lives; without it each account gets its
own directory under the per-user cache path. own directory under the per-user cache path.
An ID given to `files --collection`, `get`, `get-thumb` or `-v` (`--verbose`) and `-q` (`--quiet`) set how much quak logs on stderr. Like
`--cache-dir`, they go before or after any command's name. Every line quak
prints on stderr is a log line at one of six levels, from the quietest: fatal,
error, warning, info, debug and trace. A run prints the lines at its own level
and at every quieter one. stdout holds only the command's output, at every
level.
- Every command that exits non-zero logs a fatal line saying why, such as
`quak: <message>`, `Not logged in`, an invalid ID or a collection that does
not exist. A command that exits 1 because files or albums failed logs it last,
saying how many failed and where to look: `backup` points to `failures.json`
and its `FAILED` lines, `backup-metadata` to the `mlDataError` in the JSON of
each such file, and `helper fix-missing-thumbnails` to its `Failed files:`
list, or to its JSON with `--json`.
- An error line reports something that failed: the `FAILED` lines that `backup`
and `helper fix-missing-thumbnails` log as they work,
`ML data of file <id> could not be read` and
`ML data request for <n> file(s) failed` from `backup-metadata`,
`Could not check <title>` from both thumbnail helpers, and the `Failed files:`
list at the end of `backup` and of `helper fix-missing-thumbnails`.
- A warning reports something that did not fail but needs your attention: after
`logout`, that the cache directory still holds decrypted data.
- Every other line quak logs by default is info.
With `--json`, a command's result on stdout takes the place of its summary on
stderr: for `backup`, the lines from `--- Backup complete ---` to the end of its
`Failed files:` list; for `helper list-missing-thumbnails`,
`No missing thumbnails found.` or `<n> file(s) with missing thumbnails:`; and
for `helper fix-missing-thumbnails`, the lines from `--- Done ---` to the end of
its `Failed files:` list. Every other line is logged as it is without `--json`.
- With neither option the level is info: the lines quak prints as it works, such
as a refresh's progress, the files a backup fetches, its summary and the files
that failed.
- `-v` raises the level to debug, which adds each retry of a request, with why
the attempt failed and how long quak waits before the next, and the start and
end of each refresh and each ML data fetch, with the error of one that failed.
- `-vv` raises it to trace, which adds each request quak sends: its method, its
URL and the status of the answer, or the error the request failed with. The
URL of an upload of a thumbnail is logged without its query, which holds the
upload's signature. More repeats stay at trace, and `--verbose --verbose` is
`-vv`.
- `-q` lowers it to fatal: a run that exits 0 prints nothing on stderr, and one
that exits non-zero prints only its fatal lines. The prompts of `login` are
not log lines, and `-q` leaves them as they are.
`-q` with `-v` is refused with one line,
`error: option '-q, --quiet' cannot be used with option '-v, --verbose'`, and
exit status 1.
`files <collectionID>` lists the files in that collection. `files` with no
argument lists every file in the account, collection by collection, each file
once: a file in several collections is listed with the first of them, which is
its `collectionID` in `--json`.
An ID given to `files`, `get`, `get-thumb` or
`helper fix-missing-thumbnails --file` is written in decimal digits, no larger `helper fix-missing-thumbnails --file` is written in decimal digits, no larger
than 9007199254740991. Anything else, such as `0x10`, `1e3` or `-1`, is refused than 9007199254740991. Anything else, such as `0x10`, `1e3` or `-1`, is refused
before any request with one line, `Invalid file ID: <arg>` or before any request with one line, `Invalid file ID: <arg>` or
`Invalid collection ID: <arg>`, and exit status 1. One bad `--file` ID refuses `Invalid collection ID: <arg>`, and exit status 1. One bad `--file` ID refuses
the whole command, so nothing is fixed. the whole command, so nothing is fixed.
Without `--out`, `get` writes the file to the working directory under its title,
and `get-thumb` writes the thumbnail there under `thumb_` followed by the title,
each with unsafe characters replaced. A name over 255 bytes, which most file
systems refuse, is cut short between two characters before its extension, so the
extension is kept whenever it fits. An extension too long to leave room for the
name's first character is cut short too, so the name never starts with a dot.
`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
its image and its video, each named after the title with its own extension, as its image and its video, each named after the title with its own extension, as
Ente's clients name them (`IMG_0001.heic` and `IMG_0001.mov`). With Ente's clients name them (`IMG_0001.heic` and `IMG_0001.mov`), and each name is
`--out PATH`, the image is written to `PATH` and the video beside it, with cut short as above once its own extension is in it. With `--out PATH`, the image
`PATH`'s name and the video's extension; a `PATH` with the video's extension is is written to `PATH` and the video beside it, with `PATH`'s name and the video's
refused. `backup-metadata --exif` (alias `--all`) additionally fetches each extension; a `PATH` with the video's extension is refused.
file's original through the cache and records, from it or a live photo's image, `backup-metadata --exif` (alias `--all`) additionally fetches each file's
its XMP metadata, its EXIF metadata and, for a JPEG, its dimensions. EXIF is original through the cache and records, from it or a live photo's image, its XMP
read with [exifreader](https://github.com/mattiasw/ExifReader) from any image metadata, its EXIF metadata and, for a JPEG, its dimensions. EXIF is read with
format it reads, JPEG, HEIC/HEIF, AVIF, PNG and WebP among them. The record's [exifreader](https://github.com/mattiasw/ExifReader) from any image format it
`exif` field is exifreader's EXIF tag output: each tag by name, with its reads, JPEG, HEIC/HEIF, AVIF, PNG and WebP among them. The record's `exif` field
`value`, `description` and `computed` value. An EXIF block exifreader finds but is exifreader's EXIF tag output: each tag by name, with its `value`,
reads no tag from is recorded, base64, as `exifRaw`, with the reason in `description` and `computed` value. An EXIF block exifreader finds but reads no
`exifError`. `collections`, `files`, `backup`, `helper list-missing-thumbnails` tag from is recorded, base64, as `exifRaw`, with the reason in `exifError`.
and `helper fix-missing-thumbnails` take `--json` for machine-readable output. `collections`, `files`, `backup`, `helper list-missing-thumbnails` 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 goes any that do not match the content hash Ente records; one it cannot replace stays
into `failures.json` (see "Backup layout"). as it was and goes into `failures.json` (see "Backup layout").
`backup-metadata <dir>` writes `account.json` and, for each collection, a
directory `collections/<id>-<name>/` holding `_collection.json` and a
`<fileID>.json` for each of its files. The collection's name has unsafe
characters replaced and is cut short between two characters so that the
directory's name fits in 255 bytes; the ID keeps the directories apart.
`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. The exit code is `mlDataError` field instead of `mlData`, and the dump goes on. A file whose ML
non-zero if any ML data request failed. data does not decrypt, gunzip or parse is logged and written the same way, with
why in `mlDataError`. The exit code is non-zero if any file was written with
`mlDataError`, and the last line then says how many were.
`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
@@ -728,9 +839,10 @@ Each original is saved at its save path, the same path `photo.savePath` gives
and `photo.download()` writes (see Read surface below). The date is the photo's and `photo.download()` writes (see Read surface below). The date is the photo's
`takenAt` (the date set in Ente if it was edited, else its creation time) in the `takenAt` (the date set in Ente if it was edited, else its creation time) in the
time zone of the machine running quak. The extension is the one in the file's time zone of the machine running quak. The extension is the one in the file's
name as uploaded, case kept, or `.bin` when it has none or it holds anything but name as uploaded, case kept, or `.bin` when it has none, it holds anything but
letters and digits. When the date or the time zone changes, the next run saves letters and digits, or it is more than 16 of them long. When the date or the
the original at its new path and leaves the old copy where it is. time zone changes, the next run saves the original at its new path and leaves
the old copy where it is.
`account.json` holds the account's `email` and `userID`, as `backup-metadata` `account.json` holds the account's `email` and `userID`, as `backup-metadata`
writes it. A collection's JSON holds its `id`, `name`, `type`, `ownerID`, writes it. A collection's JSON holds its `id`, `name`, `type`, `ownerID`,
@@ -746,8 +858,15 @@ 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 JSON files are rewritten on every run, so and the next run fetches it again. The same goes for a file with no ML data in
ML data that arrived since the last run appears. the cache whose ML data the server sent but quak could not decrypt, gunzip or
parse. Its `mlDataError` says why, and `failures.json` records it with an error
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`
@@ -770,8 +889,8 @@ 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 `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 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 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 not decrypt has no name quak can read and no files it can list. `quak backup`
`--json` prints one line for it, prints one line for it on stderr,
`FAILED album <id>: could not be decrypted: <reason>`, and `--json` and the `FAILED album <id>: could not be decrypted: <reason>`, and `--json` and the
`BackupResult` list its ID in `undecryptableAlbums`. Such a file in `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, `failures.json`, or such an album, keeps the exit code at 1, run after run,
@@ -810,8 +929,24 @@ 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`. IDs never change, so a become `IMG_0001 (12345).JPG` and `IMG_0001 (12346).JPG`. A collection whose
name stays the same from run to run until such a clash appears or goes away. directory would have the name of another collection's JSON, again ignoring case,
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 leaves no room for the
ID and the first character of the name, the extension is cut short, and the part
before the ID keeps that character and any bytes the 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. `quak backup` prints `FAILED album <name>: <reason>` for it on stderr,
`--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
@@ -846,33 +981,41 @@ 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, deleted (a live photo's image and video both), and put line naming the file, and replaced once a matching copy is downloaded, in the
back in the same run like a missing one: downloaded, or copied from the cache if same run. The copy is downloaded even when the cache holds the original, because
the cache holds it. What is put back is hashed too, because a copy from the a copy from the cache is not checked as a download is. It is written to a
cache is not checked as a download is. If it still does not match, it stays at temporary file beside the original, a live photo's image and video each to its
its save path and the file goes into `failures.json`, as it does when the own, hashed the same way, and renamed over the original only when it matches; a
download fails. A file whose metadata records no hash is left as it is and live photo's image and video are both hashed before either is renamed. Until
counted as unchecked. A stored original that cannot be read is left as it is and then the original stays as it was: when the download fails, or what it brings
counts as failed. The summary and `--json` add the counts `verified`, does not match either, the temporary files are deleted, the original keeps its
`mismatched` and `unchecked`, all of originals that were already stored; one bytes and its album links, and the file goes into `failures.json`. A run that
first downloaded in this run is in none of them. A mismatch that was put back stops between renaming a live photo's new image and its new video leaves the new
with matching bytes does not make the exit code non-zero. Without `--verify` image beside the old video, which a later `--verify` run replaces if the video
nothing is hashed, the summary is unchanged, and the three counts are 0 in was the part that did not match. A file whose metadata records no hash is left
`--json`. as it is and counted as unchecked. A stored original that cannot be read is left
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 run that is `.quak-backup-YYYY-MM-DD.<fileID>.<ext>-<pid>-<random>.tmp`. A copy that
killed can leave one of these temporary files behind; the next backup deletes `--verify` downloads to replace an original is written to such a
those whose process is no longer running. The content cache uses the same `.quak-<pid>-<random>.tmp` too, a live photo's image and video to
scheme, and opening a library deletes the temporary files in the cache whose `.quak-<pid>-<random>.<ext>`, each with its own extension. A run that is killed
process is no longer running, so a download another process has in progress in can leave one of these temporary files behind; the next backup deletes those
the same cache is left alone. The rename replaces whatever was at the whose process is no longer running. The content cache uses the same scheme, and
destination rather than writing through it: a symlink there is replaced, not opening a library deletes the temporary files in the cache whose process is no
followed, and the new file has the temporary file's permissions, not those of longer running, so a download another process has in progress in the same cache
the file it replaced. is left alone. The rename replaces whatever was at the destination rather than
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
@@ -911,7 +1054,11 @@ loop, and resolves to a `Library`. On an empty cache it awaits the first
refresh, so it opens onto the account's data whenever the server is reachable; refresh, so it opens onto the account's data whenever the server is reachable;
if that refresh fails, it opens with no data and records the error in if that refresh fails, it opens with no data and records the error in
`lib.status()`. On an existing cache it returns immediately and refreshes in the `lib.status()`. On an existing cache it returns immediately and refreshes in the
background, so an unreachable server does not block opening. background, so an unreachable server does not block opening. With
`refreshOnOpen: false` it starts no refresh and returns at once onto what the
cache holds, empty or not; a refresh then starts only when `fresh()` or
`lib.backup()` asks, or the background timer fires. The CLI's read commands open
it so (see "CLI surface").
`LibraryOptions`: `LibraryOptions`:
@@ -921,6 +1068,7 @@ background, so an unreachable server does not block opening.
| `cacheDirectory` | `quak/<userID>` under the per-user cache directory | where `metadata.json` and the content cache live | | `cacheDirectory` | `quak/<userID>` under the per-user cache directory | where `metadata.json` and the content cache live |
| `downloadDirectory` | `photos` in the working directory | root of the save paths; an original stored there counts as cached | | `downloadDirectory` | `photos` in the working directory | root of the save paths; an original stored there counts as cached |
| `refreshIntervalSeconds` | `3` | background refresh cadence; `Infinity` for no background timer | | `refreshIntervalSeconds` | `3` | background refresh cadence; `Infinity` for no background timer |
| `refreshOnOpen` | `true` | start a refresh as the library opens |
| `precacheThumbnails` | `true` | prefetch every thumbnail, newest first | | `precacheThumbnails` | `true` | prefetch every thumbnail, newest first |
| `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals | | `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals |
| `precacheOriginalsDays` | `7` | length in days of that latest window | | `precacheOriginalsDays` | `7` | length in days of that latest window |
@@ -934,6 +1082,14 @@ background, so an unreachable server does not block opening.
The default `downloadDirectory` is resolved against the working directory once, The default `downloadDirectory` is resolved against the working directory once,
when the library opens; `lib.downloadDirectory` holds the result. when the library opens; `lib.downloadDirectory` holds the result.
`onProgress` receives a `RefreshEvent` with status `"started"` as a refresh, an
ML data fetch or a precache sweep starts, and `"done"` or `"failed"` as it ends.
In between, a refresh sends `"progress"` after each collection whose files it
fetched, with `fetched`, how many collections it has fetched the files of so
far, and `total`, how many it will. A refresh fetches the files of each
collection that is new or changed since the cache's copy, so on an empty cache
it fetches those of every collection.
Concurrency is set through `pools`: construct Concurrency is set through `pools`: construct
`new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })` `new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })`
and pass it. The three pools default to 10 / 5 / 25 (see Request pools below). and pass it. The three pools default to 10 / 5 / 25 (see Request pools below).
@@ -946,6 +1102,14 @@ 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
@@ -965,6 +1129,15 @@ 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).
`await lib.freshCollections()` is the fresh read of the collection list alone:
one collections request, answered with every live collection that decrypts, as
the `Collection` objects `listCollections()` returns, in the server's order. It
fetches no collection's files and stores nothing, not even the list: a refresh
fetches a collection's files only when the collection is new or has changed
since the stored copy, so a collection stored without its files would never get
them. `quak collections` uses it (issue
https://git.eeqj.de/sneak/quak/issues/210).
An album or file in the server's answer that quak cannot decrypt, or whose 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 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 album with all its files, and drops any older copy the cache held, so reads
@@ -1105,9 +1278,10 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
`downloadDirectory` (falls back to the library's), `includeOriginals` (default `downloadDirectory` (falls back to the library's), `includeOriginals` (default
`true`), `includeThumbnails` (default `false`), `onlyAlbumNames`, `verify` `true`), `includeThumbnails` (default `false`), `onlyAlbumNames`, `verify`
(default `false`), `onProgress`, and `lock`. See Backup layout above for the (default `false`), `onProgress`, and `lock`. See Backup layout above for the
tree it writes. Whether the backup took the lock or its caller did, once the tree it writes, and for the albums `BackupResult` lists in
lock is lost the backup rejects with `lost the lock on <dir>: <reason>` before `undecryptableAlbums` and `failedAlbums`. Whether the backup took the lock or
it starts another file. 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
+96 -7
View File
@@ -17,19 +17,108 @@ pre-1.0
# Next Step # Next Step
Five bugs are open: https://git.eeqj.de/sneak/quak/issues/197, No bug is open, and no change sneak asked for is open. The check of quak against
https://git.eeqj.de/sneak/quak/issues/198, a live Ente account, https://git.eeqj.de/sneak/quak/issues/187, needs an account
https://git.eeqj.de/sneak/quak/issues/199, only sneak has, so it waits on him.
https://git.eeqj.de/sneak/quak/issues/205 and
https://git.eeqj.de/sneak/quak/issues/206. The check of quak against a live Ente
account, https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak
has, so it waits on him.
Tagging and releases are decided by sneak alone, and happen only when he Tagging and releases are decided by sneak alone, and happen only when he
declares one. declares one.
# Completed Steps # Completed Steps
- 2026-10-18: `-v`/`--verbose` raises the CLI's log level once for each repeat,
and `-q`/`--quiet` prints only fatal errors
(https://git.eeqj.de/sneak/quak/issues/212). The levels are fatal, error,
warning, info, debug and trace; the default is info, everything quak printed
on stderr before, `-v` gives debug and `-vv` trace. `src/cli-log.ts` holds the
level and the log the commands write stderr lines through. Debug adds each
retry, which `withRetry` reports through the new `onRetry`, and the start and
end of each refresh and ML data fetch; trace adds each request, which
`ApiClient` reports through the new `onRequest`. `--json` no longer keeps the
progress lines off stderr, so `backup` and `--json` runs log a refresh's
progress too. Every line that reports a failed file or album is at error,
which the progress callbacks of `backup`, `backup-metadata` and the thumbnail
helpers now say, and a command that exits 1 because files or albums failed
ends with one fatal line saying how many and where to look.
- 2026-10-18: `quak files` takes the collection ID as an optional argument
instead of the `--collection` option, which is gone
(https://git.eeqj.de/sneak/quak/issues/211). With no argument it lists every
file in the account once, collection by collection; a file in several
collections is listed with the first of them, which is its `collectionID` in
`--json`. The fresh read in `src/cli-read.ts` does this.
- 2026-10-18: Names quak builds from an album name or a title fit in a file name
(https://git.eeqj.de/sneak/quak/issues/205). `shortenKeepingExtension` in
`src/filename.ts` cuts a name short to a number of bytes, keeping its
extension when it fits and always its first character, and can put text right
before the extension; the backup's `withID` uses it for an ID.
`defaultFileName` there gives the name a file is saved under when the caller
gives no path: a prefix such as `thumb_` and the title with unsafe characters
replaced, cut short with it to 255 bytes, and, for a live photo's image or
video, with that part's own extension before the cut. `quak get`,
`quak get-thumb`, `downloadFile` and `downloadThumbnail` use it without
`--out` or `outPath`. `backup-metadata` cuts an album's name short so that its
folder, `<id>-<name>`, fits in 255 bytes. `safeExtension` gives `.bin` for an
extension of more than 16 letters and digits, so a save path's name always
fits.
- 2026-10-18: `quak collections` no longer waits for the files of every
collection (issue 210). On an empty cache it opened its library with a refresh
that fetched and decrypted the files of each collection in turn, printing
nothing and storing nothing until all were done, so on a large account it
seemed to hang. It now reads the collection list alone through the new
`lib.freshCollections()`, one request that stores nothing. The read commands
open their library with the new `refreshOnOpen: false`, so each makes only the
refresh it asks for. A refresh sends a `"progress"` event after each
collection whose files it fetched, and the commands that wait on one print it
on stderr, except with `--json`.
- 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 - 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 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, error's message followed by the message of each error on its `cause` chain,
+47 -14
View File
@@ -2,8 +2,9 @@
import { stdout, stderr } from "node:process"; import { stdout, stderr } from "node:process";
import { input, password } from "@inquirer/prompts"; import { input, password } from "@inquirer/prompts";
import { Command } from "commander"; import { Command, Option } from "commander";
import envPaths from "env-paths"; import envPaths from "env-paths";
import { logLevel, logRequests, stderrLog, type Log } from "../src/cli-log.js";
import { init } from "../src/crypto/index.js"; import { init } from "../src/crypto/index.js";
import { import {
type CliContext, type CliContext,
@@ -29,6 +30,9 @@ const paths = envPaths("quak", { suffix: "" });
const program = new Command(); const program = new Command();
// Program options, so they are taken before or after any command's name.
// `-v` counts its repeats, the way commander's documentation counts a repeated
// option.
program program
.name("quak") .name("quak")
.description("CLI for the Ente end-to-end encrypted photo service") .description("CLI for the Ente end-to-end encrypted photo service")
@@ -37,21 +41,46 @@ program
"--cache-dir <path>", "--cache-dir <path>",
"Directory for the local metadata/content cache " + "Directory for the local metadata/content cache " +
"(default: the per-user cache directory)", "(default: the per-user cache directory)",
); )
.option(
"-v, --verbose",
"Log more on stderr: debug lines with -v, trace lines too with -vv",
(_value: string, previous: number) => previous + 1,
0,
)
.addOption(
new Option(
"-q, --quiet",
"Print nothing on stderr but the error a failing command exits with",
).conflicts("verbose"),
)
.configureHelp({ showGlobalOptions: true });
interface ProgramOptions {
cacheDir?: string;
verbose: number;
quiet?: boolean;
}
// The log for this run, at the level `-v` and `-q` ask for.
const log = (): Log => {
const { verbose, quiet } = program.opts<ProgramOptions>();
return stderrLog(stderr, logLevel(verbose, quiet ?? false));
};
const context = (): CliContext => ({ const context = (): CliContext => ({
stdout, stdout,
stderr, log: log(),
sessionDir: paths.data, sessionDir: paths.data,
cacheDir: program.opts<{ cacheDir?: string }>().cacheDir, cacheDir: program.opts<ProgramOptions>().cacheDir,
loadSession, loadSession: (path) => loadSession(path, logRequests(log())),
login: (opts) => Client.login(opts), login: (opts) => Client.login({ ...opts, apiOptions: logRequests(log()) }),
prompt: (message) => input({ message }), prompt: (message) => input({ message }),
promptSecret: (message) => password({ message, mask: true }), promptSecret: (message) => password({ message, mask: true }),
}); });
const run = (command: Promise<number>): Promise<void> => const run = (command: Promise<number>): Promise<void> =>
runCommand(command, stdout, stderr, (code) => process.exit(code)); runCommand(command, stdout, stderr, log(), (code) => process.exit(code));
program program
.command("login") .command("login")
@@ -78,14 +107,15 @@ program
program program
.command("files") .command("files")
.description("List files in a collection") .description("List the files in a collection, or every file in the account")
.requiredOption( .argument(
"--collection <id>", "[collectionID]",
"Collection ID (from `quak collections`)", "Collection ID (from `quak collections`); without it, every file " +
"is listed once",
) )
.option("--json", "Output as JSON array") .option("--json", "Output as JSON array")
.action((opts: { collection: string; json?: boolean }) => .action((collectionID: string | undefined, opts: { json?: boolean }) =>
run(filesCommand(context(), opts)), run(filesCommand(context(), collectionID, opts)),
); );
program program
@@ -142,7 +172,10 @@ program
{ {
...context(), ...context(),
loadSession: (path) => loadSession: (path) =>
loadSession(path, { retry: UNATTENDED_RETRY_OPTIONS }), loadSession(
path,
logRequests(log(), UNATTENDED_RETRY_OPTIONS),
),
}, },
dir, dir,
opts, opts,
+57 -12
View File
@@ -36,6 +36,15 @@ export interface ApiClientOptions {
retry?: RetryOptions; retry?: RetryOptions;
requestTimeoutMs?: number; requestTimeoutMs?: number;
downloadTimeoutMs?: number; downloadTimeoutMs?: number;
// Called once for each request sent, with the status of its response or
// the error it failed with, for a caller that logs requests. A request
// that is retried is reported once for each attempt.
onRequest?: (request: {
method: string;
url: string;
status?: number;
error?: unknown;
}) => void;
} }
export interface StreamOptions { export interface StreamOptions {
@@ -159,6 +168,7 @@ export class ApiClient {
private readonly retry: ResolvedRetryOptions; private readonly retry: ResolvedRetryOptions;
private readonly requestTimeoutMs: number; private readonly requestTimeoutMs: number;
private readonly downloadTimeoutMs: number; private readonly downloadTimeoutMs: number;
private readonly onRequest: NonNullable<ApiClientOptions["onRequest"]>;
private token: string | undefined; private token: string | undefined;
constructor(opts?: ApiClientOptions) { constructor(opts?: ApiClientOptions) {
@@ -180,6 +190,7 @@ export class ApiClient {
opts?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; opts?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
this.downloadTimeoutMs = this.downloadTimeoutMs =
opts?.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS; opts?.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS;
this.onRequest = opts?.onRequest ?? (() => {});
this.token = opts?.authToken; this.token = opts?.authToken;
} }
@@ -214,6 +225,33 @@ export class ApiClient {
return h; return h;
} }
// Send one request and report it to `onRequest`, with the status of its
// response or the error it failed with. `reportedURL` is the URL given to
// `onRequest`, when that must not be the one sent.
private async send(
url: string,
init: RequestInit & { method: string },
reportedURL = url,
): Promise<Response> {
let resp: Response;
try {
resp = await this._fetch(url, init);
} catch (err) {
this.onRequest({
method: init.method,
url: reportedURL,
error: err,
});
throw err;
}
this.onRequest({
method: init.method,
url: reportedURL,
status: resp.status,
});
return resp;
}
private async throwIfError(resp: Response): Promise<void> { private async throwIfError(resp: Response): Promise<void> {
if (resp.ok) return; if (resp.ok) return;
const requestID = resp.headers.get("x-request-id") ?? undefined; const requestID = resp.headers.get("x-request-id") ?? undefined;
@@ -257,7 +295,7 @@ export class ApiClient {
const url = buildURL(this.apiOrigin, path, query); const url = buildURL(this.apiOrigin, path, query);
// A GET changes nothing, so it is retried under the full policy. // A GET changes nothing, so it is retried under the full policy.
return withRetry(async () => { return withRetry(async () => {
const resp = await this._fetch(url, { const resp = await this.send(url, {
method: "GET", method: "GET",
headers: this.headers(), headers: this.headers(),
signal: AbortSignal.timeout(this.requestTimeoutMs), signal: AbortSignal.timeout(this.requestTimeoutMs),
@@ -280,7 +318,7 @@ export class ApiClient {
// 3xx status, which is not retried. // 3xx status, which is not retried.
return withRetry( return withRetry(
async () => { async () => {
const resp = await this._fetch(url, { const resp = await this.send(url, {
method: "POST", method: "POST",
headers: this.headers({ headers: this.headers({
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -321,16 +359,23 @@ export class ApiClient {
// object at one key in one request, so replaying it either overwrites // object at one key in one request, so replaying it either overwrites
// the same bytes or lands them for the first time. There is no partial // the same bytes or lands them for the first time. There is no partial
// state to protect, hence the full policy rather than the POST rule. // state to protect, hence the full policy rather than the POST rule.
// It is reported without its query, which holds the upload's
// signature: a log keeps what it is given.
const { origin, pathname } = new URL(presignedURL);
await withRetry(async () => { await withRetry(async () => {
const resp = await this._fetch(presignedURL, { const resp = await this.send(
method: "PUT", presignedURL,
headers: { {
"Content-Type": "application/octet-stream", method: "PUT",
"Content-Length": String(data.length), headers: {
"Content-Type": "application/octet-stream",
"Content-Length": String(data.length),
},
body: data,
signal: AbortSignal.timeout(this.requestTimeoutMs),
}, },
body: data, origin + pathname,
signal: AbortSignal.timeout(this.requestTimeoutMs), );
});
if (!resp.ok) { if (!resp.ok) {
// An ApiError, not a bare Error: without the status on the // An ApiError, not a bare Error: without the status on the
// error the upload path cannot be classified at all, and a // error the upload path cannot be classified at all, and a
@@ -348,7 +393,7 @@ export class ApiClient {
// Same replay and redirect rules as `postJSON`, for the same reasons. // Same replay and redirect rules as `postJSON`, for the same reasons.
return withRetry( return withRetry(
async () => { async () => {
const resp = await this._fetch(url, { const resp = await this.send(url, {
method: "PUT", method: "PUT",
headers: this.headers({ headers: this.headers({
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -394,7 +439,7 @@ export class ApiClient {
// headers, when no bytes have arrived either. // headers, when no bytes have arrived either.
const deadline = idleDeadline(this.downloadTimeoutMs); const deadline = idleDeadline(this.downloadTimeoutMs);
try { try {
const resp = await this._fetch(url, { const resp = await this.send(url, {
method: "GET", method: "GET",
headers: this.headers(), headers: this.headers(),
signal: deadline.signal, signal: deadline.signal,
+164 -127
View File
@@ -41,8 +41,9 @@
// //
// 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 removed and fetched again in the same run. What is put // metadata records is replaced once a matching copy is downloaded, in the same
// back is hashed too, and recorded as failed if it still does not match. // run. Until then it stays as it is: a download that fails, or that does not
// 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
@@ -50,7 +51,9 @@
// 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 // 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 // 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. // 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
@@ -59,7 +62,6 @@
// code. // code.
import { import {
createReadStream,
lstatSync, lstatSync,
mkdirSync, mkdirSync,
readdirSync, readdirSync,
@@ -75,20 +77,22 @@ import { readFile } from "node:fs/promises";
import { dirname, extname, join, relative, resolve } from "node:path"; import { dirname, extname, join, relative, resolve } from "node:path";
import lockfile from "proper-lockfile"; import lockfile from "proper-lockfile";
import {
chunkHashFinal,
chunkHashInit,
chunkHashUpdate,
init,
} from "./crypto/index.js";
import { removeLeftoverTempFiles } from "./download/index.js"; import { removeLeftoverTempFiles } from "./download/index.js";
import { describeError } from "./errors.js"; import { describeError } from "./errors.js";
import { sanitizeFileName, withExtension } from "./filename.js"; import {
MAX_NAME_BYTES,
sanitizeFileName,
shortenKeepingExtension,
shortenToBytes,
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 type { Undecryptable } from "./library/store.js";
@@ -97,7 +101,10 @@ 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"; import { hasTransportCode } from "./retry.js";
export type ProgressCallback = (message: string) => void; // Reports one line of the backup's progress. `level` is "error" when the line
// reports something that failed, such as a file or an album, and is left out
// for every other line.
export type ProgressCallback = (message: string, level?: "error") => void;
// The lock in a backup directory, as `lockBackupDirectory` returns it. // The lock in a backup directory, as `lockBackupDirectory` returns it.
export interface BackupLock { export interface BackupLock {
@@ -145,9 +152,9 @@ export interface BackupResult {
// Originals already at their save path and left untouched. // Originals already at their save path and left untouched.
skipped: number; skipped: number;
// With `verify`, the originals already at their save path whose hash // With `verify`, the originals already at their save path whose hash
// matched, those whose hash did not (each removed and fetched again), and // matched, those whose hash did not (each replaced once a matching copy is
// those whose metadata records no hash (left as they are). All three are // downloaded), and those whose metadata records no hash (left as they
// zero without `verify`. // are). All three are zero without `verify`.
verified: number; verified: number;
mismatched: number; mismatched: number;
unchecked: number; unchecked: number;
@@ -160,6 +167,9 @@ export interface BackupResult {
// The IDs of the albums the library could not decrypt, whose files it // 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. // could not list; the CLI exits non-zero while there are any.
undecryptableAlbums: number[]; 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
@@ -182,6 +192,15 @@ export interface BackupLibrary {
fileID: number, fileID: number,
destination: string, destination: string,
): Promise<{ path: string; videoPath?: string }>; ): Promise<{ path: string; videoPath?: string }>;
// Fetch an original's bytes to `destination` through the content pool,
// a live photo as its image and its video beside it, even when the cache
// or the library's save path already holds it, and return where they
// landed. With `verify`, an original whose hash does not match is replaced
// with what this fetches.
fetchOriginal(
fileID: number,
destination: string,
): Promise<{ path: string; videoPath?: string }>;
thumbnail(fileID: number): Promise<{ path: string }>; thumbnail(fileID: number): Promise<{ path: string }>;
// How many originals the backup works on at once: the limit of the // How many originals the backup works on at once: the limit of the
// library's content pool. // library's content pool.
@@ -193,6 +212,11 @@ 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";
@@ -223,10 +247,13 @@ const isPresent = (path: string): boolean => {
// 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. The error code and the error as `describeError`
// gives it are inspected, and so is each errno on the error's cause chain, // gives it are inspected, and so is each errno on the error's cause chain,
// where Node's `fetch` puts it. // 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"; 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 = `${e?.code ?? ""} ${describeError(err)}`.toLowerCase(); const text = `${e?.code ?? ""} ${describeError(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(
@@ -264,31 +291,46 @@ 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); then every entry sharing it gets ` (<id>)`, before the // file system), or it is too long for a file name; then it gets ` (<id>)`,
// extension when `beforeExtension` is set. A name with an ID added can match // right before the extension when `beforeExtension` is set. To fit, the part
// another entry's own name (`IMG (6).JPG`), so this repeats until no name is // before the ID is cut short as far as it must be. When the extension leaves
// shared. IDs are stable, so the names are too. // no room for the ID and the first character of the name, the extension is
// cut short, and the part before the ID keeps that character and any bytes
// the cut leaves over (see `shortenKeepingExtension`). 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 }[],
beforeExtension: boolean, opts: { beforeExtension?: boolean; withJSON?: 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 ext = beforeExtension ? extname(name) : ""; const tag = ` (${id})`;
const stem = name.slice(0, name.length - ext.length); return opts.beforeExtension
return `${stem} (${id})${ext}`; ? shortenKeepingExtension(name, maxBytes, tag)
: shortenToBytes(name, maxBytes - Buffer.byteLength(tag)) + tag;
}; };
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>();
for (const name of names) { const count = (name: string): void => {
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;
if (counts.get(name.toLowerCase()) === 1) continue; const fits = Buffer.byteLength(name) <= maxBytes;
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;
@@ -415,26 +457,6 @@ 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.
@@ -635,7 +657,7 @@ const runLockedBackup = async (
for (const record of lib.undecryptable()) { for (const record of lib.undecryptable()) {
const error = `could not be decrypted: ${record.error}`; const error = `could not be decrypted: ${record.error}`;
if (record.fileID === undefined) { if (record.fileID === undefined) {
log(`FAILED album ${record.collectionID}: ${error}`); log(`FAILED album ${record.collectionID}: ${error}`, "error");
undecryptableAlbums.push(record.collectionID); undecryptableAlbums.push(record.collectionID);
} else if (scope.has(record.collectionID)) { } else if (scope.has(record.collectionID)) {
const copies = memberships.get(record.fileID); const copies = memberships.get(record.fileID);
@@ -656,11 +678,13 @@ const runLockedBackup = async (
// through the content cache/pools, as `Photo.download()` does, and fetch // through the content cache/pools, as `Photo.download()` does, and fetch
// the optional thumbnails; a present file is left as is. With `verify`, a // the optional thumbnails; a present file is left as is. With `verify`, a
// present original is hashed first, and one that does not match the hash // present original is hashed first, and one that does not match the hash
// its metadata records is removed and fetched like a missing one. One // its metadata records is downloaded again and replaced by
// that cannot be read is recorded as failed and left where it is. // `replaceOriginal`, which leaves it as it was when that download fails
// `storeOriginal` does this for one original, and records its failure. // or does not match either. One that cannot be read is recorded as failed
// and left where it is. `storeOriginal` does this for one original, and
// records its failure.
const storeOriginal = async (file: EnteFile): Promise<void> => { const storeOriginal = async (file: EnteFile): Promise<void> => {
let stored = storedAtSavePath(downloadDirectory, file); const stored = storedAtSavePath(downloadDirectory, file);
let mismatch = false; let mismatch = false;
if (stored !== undefined && verify) { if (stored !== undefined && verify) {
try { try {
@@ -674,15 +698,11 @@ const runLockedBackup = async (
); );
mismatched++; mismatched++;
mismatch = true; mismatch = true;
rmSync(stored.path);
if (stored.videoPath !== undefined) {
rmSync(stored.videoPath);
}
stored = undefined;
} }
} catch (err) { } catch (err) {
log( log(
`FAILED verifying original ${file.metadata.title}: ${describeError(err)}`, `FAILED verifying original ${file.metadata.title}: ${describeError(err)}`,
"error",
); );
recordFailure( recordFailure(
file.id, file.id,
@@ -693,33 +713,30 @@ const runLockedBackup = async (
return; return;
} }
} }
if (stored !== undefined) { if (stored !== undefined && !mismatch) {
skipped++; skipped++;
return; return;
} }
try { try {
log(`Fetching original ${file.metadata.title} (${file.id})...`); log(`Fetching original ${file.metadata.title} (${file.id})...`);
// A fetched original is written straight to its save path (a if (mismatch) {
// live photo beside it); only one that was already cached await replaceOriginal(downloadDirectory, file, (dest) =>
// elsewhere is copied. lib.fetchOriginal(file.id, dest),
const placed = await placeOriginal( );
downloadDirectory, } else {
file, // A fetched original is written straight to its save path (a
(dest) => lib.original(file.id, dest), // live photo beside it); only one that was already cached
); // elsewhere is copied.
storedThisRun.add(file.id); await placeOriginal(downloadDirectory, file, (dest) =>
// A copy from the cache is not checked as a download is and can lib.original(file.id, dest),
// hold the same bad bytes, so what is put back after a mismatch
// is hashed too. A bad copy stays where it is and the file fails.
if (mismatch && (await storedHash(placed)) !== file.metadata.hash) {
throw new Error(
"the original put back does not match its content hash either",
); );
} }
storedThisRun.add(file.id);
downloaded++; downloaded++;
} catch (err) { } catch (err) {
log( log(
`FAILED original ${file.metadata.title}: ${describeError(err)}`, `FAILED original ${file.metadata.title}: ${describeError(err)}`,
"error",
); );
recordFailure( recordFailure(
file.id, file.id,
@@ -784,19 +801,18 @@ 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 fails, a file with no cached ML data gets the reason // When the fetch got a file's ML data but could not decrypt, gunzip or
// instead and is recorded as failed. The next run fetches its ML data // parse it, or when the fetch failed, a file with no cached ML data gets
// again because none is cached. // the reason instead and is recorded as failed. The next run fetches its
// ML data again because none is cached.
if (includeOriginals) { if (includeOriginals) {
let mlDataFailure: unknown; 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; mlDataFailure = err;
mlDataError = describeError(err); log(`FAILED ML data: ${describeError(err)}`, "error");
log(`FAILED ML data: ${mlDataError}`);
} }
for (const file of distinct.values()) { for (const file of distinct.values()) {
if (lock.lost) throw lock.lost; if (lock.lost) throw lock.lost;
@@ -813,13 +829,19 @@ 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);
if (mlData === undefined && mlDataError !== undefined) { const failure = lib.mlDataError(file.id) ?? mlDataFailure;
writeSidecar(path, file, { mlDataError }, image); if (mlData === undefined && failure !== undefined) {
writeSidecar(
path,
file,
{ mlDataError: describeError(failure) },
image,
);
recordFailure( recordFailure(
file.id, file.id,
file.metadata.title, file.metadata.title,
collectionName.get(file.collectionID) ?? "", collectionName.get(file.collectionID) ?? "",
new Error("ML data", { cause: mlDataFailure }), new Error("ML data", { cause: failure }),
); );
} else { } else {
writeSidecar(path, file, { mlData }, image); writeSidecar(path, file, { mlData }, image);
@@ -838,7 +860,7 @@ const runLockedBackup = async (
id: c.id, id: c.id,
name: sanitizeFileName(c.name, `collection-${c.id}`), name: sanitizeFileName(c.name, `collection-${c.id}`),
})), })),
false, { withJSON: true },
); );
const albumDirNames = new Map( const albumDirNames = new Map(
allCollections.map((c, i) => [c.id, dirNames[i]!]), allCollections.map((c, i) => [c.id, dirNames[i]!]),
@@ -851,63 +873,77 @@ const runLockedBackup = async (
downloadDirectory, downloadDirectory,
); );
} catch (err) { } catch (err) {
log(`FAILED removing old album directories: ${describeError(err)}`); log(
`FAILED removing old album directories: ${describeError(err)}`,
"error",
);
} }
// 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; if (lock.lost) throw lock.lost;
const colDirName = albumDirNames.get(c.id)!;
const colDir = join(collectionsDir, colDirName);
mkdirSync(colDir, { recursive: true });
// Every album links the one original, saved from the file's entry in
// `distinct`.
const files = filesByCollection.get(c.id) ?? [];
const links = files.flatMap((f) =>
linksFor(
f,
storedAtSavePath(downloadDirectory, distinct.get(f.id)!),
),
);
const linkNames = uniqueNames(links, true);
try { try {
removeStaleLinks(colDir, new Set(linkNames), downloadDirectory); const colDirName = albumDirNames.get(c.id)!;
} catch (err) { const colDir = join(collectionsDir, colDirName);
log( mkdirSync(colDir, { recursive: true });
`FAILED removing old links in ${c.name}: ${describeError(err)}`,
);
}
const metaFiles = files.map((f) => ({ // Every album links the one original, saved from the file's entry
id: f.id, // in `distinct`.
metadata: f.metadata, const files = filesByCollection.get(c.id) ?? [];
})); const links = files.flatMap((f) =>
for (const [i, link] of links.entries()) { linksFor(
if (!includeOriginals || link.target === undefined) continue; f,
const linkName = linkNames[i]!; storedAtSavePath(downloadDirectory, distinct.get(f.id)!),
),
);
const linkNames = uniqueNames(links, { beforeExtension: true });
try { try {
rebuildSymlink( removeStaleLinks(colDir, new Set(linkNames), downloadDirectory);
join(colDir, linkName),
relative(colDir, link.target),
);
} catch (err) { } catch (err) {
log( log(
`FAILED symlink ${c.name}/${linkName}: ${describeError(err)}`, `FAILED removing old links in ${c.name}: ${describeError(err)}`,
); "error",
recordFailure(
link.file.id,
link.file.metadata.title,
c.name,
err,
); );
} }
}
writeAlbumJSON( const metaFiles = files.map((f) => ({
join(collectionsDir, `${colDirName}.json`), id: f.id,
c, metadata: f.metadata,
metaFiles, }));
); 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)}`,
"error",
);
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)}`, "error");
failedAlbums.push(c.id);
}
} }
// Reconcile the ledger against what this run actually attempted: an entry // Reconcile the ledger against what this run actually attempted: an entry
@@ -933,6 +969,7 @@ const runLockedBackup = async (
failed: ledger.size, failed: ledger.size,
errors, errors,
undecryptableAlbums, undecryptableAlbums,
failedAlbums,
}; };
}; };
+128 -97
View File
@@ -4,7 +4,10 @@
// code; a thrown error is left to the caller. Nothing here calls // code; a thrown error is left to the caller. Nothing here calls
// `process.exit`: `bin/quak.ts` wires these to the command line, and `run` in // `process.exit`: `bin/quak.ts` wires these to the command line, and `run` in
// `cli-run.ts` prints a thrown error as one line and exits once output has // `cli-run.ts` prints a thrown error as one line and exits once output has
// drained. Output must stay byte-identical (see `cli-output.ts`). // drained. Output must stay byte-identical (see `cli-output.ts`). Each line on
// stderr goes through `ctx.log` at its level (see `cli-log.ts`). Every command
// that exits non-zero logs a fatal line saying why; one that exits 1 because
// files or albums failed logs it last, saying how many and where to look.
import { import {
copyFileSync, copyFileSync,
@@ -33,6 +36,7 @@ import {
originalName, originalName,
thumbnailName, thumbnailName,
} from "./cli-output.js"; } from "./cli-output.js";
import { type Log, logRefresh } from "./cli-log.js";
import { freshCollections, freshFiles, freshFile } from "./cli-read.js"; import { freshCollections, freshFiles, freshFile } from "./cli-read.js";
import { withExtension } from "./filename.js"; import { withExtension } from "./filename.js";
import { runMetadataBackup } from "./metadata-backup.js"; import { runMetadataBackup } from "./metadata-backup.js";
@@ -40,7 +44,8 @@ import { listMissingThumbnails, fixMissingThumbnails } from "./thumbnails.js";
export interface CliContext { export interface CliContext {
stdout: { write(text: string): unknown }; stdout: { write(text: string): unknown };
stderr: { write(text: string): unknown }; // Writes a line on stderr at a log level, if the run's level prints it.
log: Log;
// Directory holding `session.json`. // Directory holding `session.json`.
sessionDir: string; sessionDir: string;
// The `--cache-dir` global, or undefined to let the library pick its // The `--cache-dir` global, or undefined to let the library pick its
@@ -82,16 +87,16 @@ const requireSession = (ctx: CliContext): Client | undefined => {
try { try {
client = ctx.loadSession(sessionPath(ctx)); client = ctx.loadSession(sessionPath(ctx));
} catch (err) { } catch (err) {
ctx.stderr.write( ctx.log("fatal", err instanceof Error ? err.message : String(err));
`${err instanceof Error ? err.message : err}\n` + ctx.log(
`Run "quak logout" and then "quak login" to replace it.\n`, "fatal",
`Run "quak logout" and then "quak login" to replace it.`,
); );
return undefined; return undefined;
} }
if (!client) { if (!client) {
ctx.stderr.write( ctx.log("fatal", `Not logged in. Run "quak login" first.`);
`Not logged in. Run "quak login" first.\nSession file: ${sessionPath(ctx)}\n`, ctx.log("fatal", `Session file: ${sessionPath(ctx)}`);
);
return undefined; return undefined;
} }
return client; return client;
@@ -107,7 +112,7 @@ const parseID = (
): number | undefined => { ): number | undefined => {
const id = Number(text); const id = Number(text);
if (/^[0-9]+$/.test(text) && Number.isSafeInteger(id)) return id; if (/^[0-9]+$/.test(text) && Number.isSafeInteger(id)) return id;
ctx.stderr.write(`Invalid ${kind} ID: ${text}\n`); ctx.log("fatal", `Invalid ${kind} ID: ${text}`);
return undefined; return undefined;
}; };
@@ -125,15 +130,19 @@ const readLibraryClient = (client: Client): LibraryClient => ({
// Open a library for a single point command: the aggressive background precache // Open a library for a single point command: the aggressive background precache
// (issue #48) is off — a one-shot `collections` or `get` must not start // (issue #48) is off — a one-shot `collections` or `get` must not start
// downloading the whole account — and the refresh interval is long so no second // downloading the whole account — and no refresh starts as it opens or on a
// refresh fires mid-command. // timer: each command asks for the one it needs, and `collections` needs none
// (issue #210). While a refresh runs, each collection whose files it has
// fetched is logged, so a large account shows progress rather than nothing.
const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> => const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> =>
Library.open({ Library.open({
client: readLibraryClient(client), client: readLibraryClient(client),
cacheDirectory: ctx.cacheDir, cacheDirectory: ctx.cacheDir,
refreshIntervalSeconds: 3600, refreshOnOpen: false,
refreshIntervalSeconds: Infinity,
precacheThumbnails: false, precacheThumbnails: false,
precacheOriginals: false, precacheOriginals: false,
onProgress: logRefresh(ctx.log),
}); });
export const loginCommand = async (ctx: CliContext): Promise<number> => { export const loginCommand = async (ctx: CliContext): Promise<number> => {
@@ -142,7 +151,7 @@ export const loginCommand = async (ctx: CliContext): Promise<number> => {
const password = const password =
process.env.QUAK_PASSWORD ?? (await ctx.promptSecret("Password")); process.env.QUAK_PASSWORD ?? (await ctx.promptSecret("Password"));
ctx.stderr.write("Authenticating...\n"); ctx.log("info", "Authenticating...");
try { try {
const client = await ctx.login({ const client = await ctx.login({
email, email,
@@ -153,11 +162,12 @@ export const loginCommand = async (ctx: CliContext): Promise<number> => {
saveSession(ctx.sessionDir, client.toJSON()); saveSession(ctx.sessionDir, client.toJSON());
const info = client.whoami(); const info = client.whoami();
ctx.stderr.write(`Logged in as ${info.email} (user ${info.userID})\n`); ctx.log("info", `Logged in as ${info.email} (user ${info.userID})`);
ctx.stderr.write(`Session saved to ${sessionPath(ctx)}\n`); ctx.log("info", `Session saved to ${sessionPath(ctx)}`);
} catch (err) { } catch (err) {
ctx.stderr.write( ctx.log(
`Login failed: ${err instanceof Error ? err.message : err}\n`, "fatal",
`Login failed: ${err instanceof Error ? err.message : err}`,
); );
return 1; return 1;
} }
@@ -179,7 +189,7 @@ export const whoamiCommand = async (ctx: CliContext): Promise<number> => {
export const logoutCommand = async (ctx: CliContext): Promise<number> => { export const logoutCommand = async (ctx: CliContext): Promise<number> => {
const path = sessionPath(ctx); const path = sessionPath(ctx);
if (!existsSync(path)) { if (!existsSync(path)) {
ctx.stderr.write("No session found.\n"); ctx.log("info", "No session found.");
return 0; return 0;
} }
await init(); await init();
@@ -197,16 +207,15 @@ export const logoutCommand = async (ctx: CliContext): Promise<number> => {
} }
unlinkSync(path); unlinkSync(path);
if (failure === undefined) { if (failure === undefined) {
ctx.stderr.write("Session ended on the server.\n"); ctx.log("info", "Session ended on the server.");
} else { } else {
ctx.stderr.write( ctx.log("fatal", `Could not end the session on the server: ${failure}`);
`Could not end the session on the server: ${failure}\n`,
);
} }
ctx.stderr.write("Session deleted.\n"); ctx.log("info", "Session deleted.");
if (cacheDir !== undefined) { if (cacheDir !== undefined) {
ctx.stderr.write( ctx.log(
`Cache directory ${cacheDir} still holds decrypted data; delete it to remove that data.\n`, "warning",
`Cache directory ${cacheDir} still holds decrypted data; delete it to remove that data.`,
); );
} }
return failure === undefined ? 0 : 1; return failure === undefined ? 0 : 1;
@@ -221,9 +230,10 @@ export const collectionsCommand = async (
if (!client) return 3; if (!client) return 3;
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
// Force a server round-trip and list in enumeration order (issue #36 // Ask the server for the collection list alone and list it in its
// amendment, issue #52): the pre-library CLI printed current state in // order (issue #36 amendment, issues #52 and #210): the pre-library
// this order, not the albums projection's newest-first order. // CLI printed current state in this order, not the albums
// projection's newest-first order.
const collections = await freshCollections(lib); const collections = await freshCollections(lib);
if (opts.json) { if (opts.json) {
@@ -254,15 +264,21 @@ export const collectionsCommand = async (
} }
}; };
// Lists the files of the collection `collectionIDStr` names, or without it every
// file in the account, each once (issue #211).
export const filesCommand = async ( export const filesCommand = async (
ctx: CliContext, ctx: CliContext,
opts: { collection: string; json?: boolean }, collectionIDStr: string | undefined,
opts: { json?: boolean },
): Promise<number> => { ): Promise<number> => {
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 =
if (collectionID === undefined) return 1; collectionIDStr === undefined
? undefined
: parseID(ctx, "collection", collectionIDStr);
if (collectionIDStr !== undefined && collectionID === undefined) return 1;
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
@@ -273,7 +289,7 @@ export const filesCommand = async (
// newest-first order. // newest-first order.
const files = await freshFiles(lib, collectionID); const files = await freshFiles(lib, collectionID);
if (!files) { if (!files) {
ctx.stderr.write(`Collection ${collectionID} not found\n`); ctx.log("fatal", `Collection ${collectionID} not found`);
return 1; return 1;
} }
@@ -309,7 +325,7 @@ export const getCommand = async (
// (issue #36 amendment, issue #52). // (issue #36 amendment, issue #52).
const resolved = await freshFile(lib, fileID); const resolved = await freshFile(lib, fileID);
if (!resolved) { if (!resolved) {
ctx.stderr.write(`File ${fileID} not found\n`); ctx.log("fatal", `File ${fileID} not found`);
return 1; return 1;
} }
const { photo, file } = resolved; const { photo, file } = resolved;
@@ -317,30 +333,33 @@ export const getCommand = async (
const result = await photo.original(); const result = await photo.original();
// Default name is the file's own title, as the pre-library CLI used // Default name is the file's own title, as the pre-library CLI used
// (not the editedName-preferring projection title) (issue #52). // (not the editedName-preferring projection title) (issue #52).
const outPath = opts.out ?? originalName(file);
if (result.videoPath === undefined) { if (result.videoPath === undefined) {
const outPath = opts.out ?? originalName(file);
copyFileSync(result.path, outPath); copyFileSync(result.path, outPath);
ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`); ctx.log("info", `${result.bytes} bytes -> ${outPath}`);
return 0; return 0;
} }
// A live photo is written as its image and its video, each named after // A live photo is written as its image and its video, each named after
// the title with its own extension, as Ente's clients name them. With // the title with its own extension, as Ente's clients name them. With
// --out, the image goes there and the video beside it. // --out, the image goes there and the video beside it.
const imageOut = const imageExt = extname(result.path);
opts.out ?? withExtension(outPath, extname(result.path)); const videoExt = extname(result.videoPath);
const videoOut = withExtension(outPath, extname(result.videoPath)); const imageOut = opts.out ?? originalName(file, imageExt);
const videoOut =
opts.out === undefined
? originalName(file, videoExt)
: withExtension(opts.out, videoExt);
if (imageOut.toLowerCase() === videoOut.toLowerCase()) { if (imageOut.toLowerCase() === videoOut.toLowerCase()) {
ctx.stderr.write( ctx.log(
`File ${fileID} is a live photo, and its video would also be written to ${imageOut}\n`, "fatal",
`File ${fileID} is a live photo, and its video would also be written to ${imageOut}`,
); );
return 1; return 1;
} }
copyFileSync(result.path, imageOut); copyFileSync(result.path, imageOut);
copyFileSync(result.videoPath, videoOut); copyFileSync(result.videoPath, videoOut);
ctx.stderr.write( ctx.log("info", `${result.bytes} bytes -> ${imageOut}`);
`${result.bytes} bytes -> ${imageOut}\n` + ctx.log("info", `${statSync(videoOut).size} bytes -> ${videoOut}`);
`${statSync(videoOut).size} bytes -> ${videoOut}\n`,
);
return 0; return 0;
} finally { } finally {
await lib.close(); await lib.close();
@@ -364,7 +383,7 @@ export const getThumbCommand = async (
// (issue #36 amendment, issue #52). // (issue #36 amendment, issue #52).
const resolved = await freshFile(lib, fileID); const resolved = await freshFile(lib, fileID);
if (!resolved) { if (!resolved) {
ctx.stderr.write(`File ${fileID} not found\n`); ctx.log("fatal", `File ${fileID} not found`);
return 1; return 1;
} }
const { photo, file } = resolved; const { photo, file } = resolved;
@@ -374,7 +393,7 @@ export const getThumbCommand = async (
// used (not the projection title) (issue #52). // used (not the projection title) (issue #52).
const outPath = opts.out ?? thumbnailName(file); const outPath = opts.out ?? thumbnailName(file);
copyFileSync(result.path, outPath); copyFileSync(result.path, outPath);
ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`); ctx.log("info", `${result.bytes} bytes -> ${outPath}`);
return 0; return 0;
} finally { } finally {
await lib.close(); await lib.close();
@@ -394,11 +413,16 @@ 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 { failedMLBatches } = await runMetadataBackup(lib, client, dir, { const result = await runMetadataBackup(lib, client, dir, {
exif: opts.exif || opts.all, exif: opts.exif || opts.all,
onProgress: (msg) => ctx.stderr.write(msg + "\n"), onProgress: (msg, level) => ctx.log(level ?? "info", msg),
}); });
return failedMLBatches > 0 ? 1 : 0; if (result.filesWithMLDataError === 0) return 0;
ctx.log(
"fatal",
`quak: the ML data of ${result.filesWithMLDataError} file(s) could not be read; see mlDataError in their JSON in ${dir}`,
);
return 1;
} finally { } finally {
await lib.close(); await lib.close();
} }
@@ -413,7 +437,7 @@ export const backupCommand = async (
const client = requireSession(ctx); const client = requireSession(ctx);
if (!client) return 3; if (!client) return 3;
ctx.stderr.write("Starting backup...\n"); ctx.log("info", "Starting backup...");
// 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 lock: BackupLock;
@@ -421,7 +445,7 @@ export const backupCommand = async (
lock = await lockBackupDirectory(dir); lock = 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.log("fatal", `quak: ${(err as Error).message}`);
return 2; return 2;
} }
try { try {
@@ -436,43 +460,48 @@ export const backupCommand = async (
refreshIntervalSeconds: Infinity, refreshIntervalSeconds: Infinity,
precacheThumbnails: false, precacheThumbnails: false,
precacheOriginals: false, precacheOriginals: false,
onProgress: logRefresh(ctx.log),
}); });
try { try {
const result = await lib.backup({ const result = await lib.backup({
downloadDirectory: dir, downloadDirectory: dir,
lock, lock,
verify: opts.verify, verify: opts.verify,
onProgress: (msg) => { onProgress: (msg, level) => ctx.log(level ?? "info", msg),
if (!opts.json) ctx.stderr.write(msg + "\n");
},
}); });
if (opts.json) { if (opts.json) {
ctx.stdout.write(JSON.stringify(result, null, 2) + "\n"); ctx.stdout.write(JSON.stringify(result, null, 2) + "\n");
} else { } else {
ctx.stderr.write("\n--- Backup complete ---\n"); ctx.log("info", "\n--- Backup complete ---");
ctx.stderr.write(` Total files: ${result.totalFiles}\n`); ctx.log("info", ` Total files: ${result.totalFiles}`);
ctx.stderr.write(` Downloaded: ${result.downloaded}\n`); ctx.log("info", ` Downloaded: ${result.downloaded}`);
ctx.stderr.write(` Skipped: ${result.skipped}\n`); ctx.log("info", ` Skipped: ${result.skipped}`);
if (opts.verify) { if (opts.verify) {
ctx.stderr.write(` Verified: ${result.verified}\n`); ctx.log("info", ` Verified: ${result.verified}`);
ctx.stderr.write(` Mismatched: ${result.mismatched}\n`); ctx.log("info", ` Mismatched: ${result.mismatched}`);
ctx.stderr.write(` Unchecked: ${result.unchecked}\n`); ctx.log("info", ` Unchecked: ${result.unchecked}`);
} }
ctx.stderr.write(` Failed: ${result.failed}\n`); ctx.log("info", ` Failed: ${result.failed}`);
if (result.errors.length > 0) { if (result.errors.length > 0) {
ctx.stderr.write("\nFailed files:\n"); ctx.log("error", "\nFailed files:");
for (const e of result.errors) { for (const e of result.errors) {
ctx.stderr.write( ctx.log(
` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`, "error",
` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}`,
); );
} }
} }
} }
return result.failed > 0 || result.undecryptableAlbums.length > 0 const albums =
? 1 result.undecryptableAlbums.length + result.failedAlbums.length;
: 0; if (result.failed === 0 && albums === 0) return 0;
ctx.log(
"fatal",
`quak: ${result.failed} file(s) and ${albums} album(s) failed; see ${join(dir, "failures.json")} and the FAILED lines`,
);
return 1;
} finally { } finally {
await lib.close(); await lib.close();
} }
@@ -493,18 +522,19 @@ export const listMissingThumbnailsCommand = async (
// Refresh first so files added since the cache was written are // Refresh first so files added since the cache was written are
// checked; a failed refresh throws. // checked; a failed refresh throws.
await lib.fresh(); await lib.fresh();
const missing = await listMissingThumbnails(lib, client, (msg) => { const missing = await listMissingThumbnails(lib, client, (msg, level) =>
if (!opts.json) ctx.stderr.write(msg + "\n"); ctx.log(level ?? "info", msg),
}); );
if (opts.json) { if (opts.json) {
ctx.stdout.write(JSON.stringify(missing, null, 2) + "\n"); ctx.stdout.write(JSON.stringify(missing, null, 2) + "\n");
} else { } else {
if (missing.length === 0) { if (missing.length === 0) {
ctx.stderr.write("No missing thumbnails found.\n"); ctx.log("info", "No missing thumbnails found.");
} else { } else {
ctx.stderr.write( ctx.log(
`\n${missing.length} file(s) with missing thumbnails:\n`, "info",
`\n${missing.length} file(s) with missing thumbnails:`,
); );
for (const m of missing) { for (const m of missing) {
ctx.stdout.write( ctx.stdout.write(
@@ -539,27 +569,28 @@ export const fixMissingThumbnailsCommand = async (
// a failed refresh throws. // a failed refresh throws.
await lib.fresh(); await lib.fresh();
if (fileIDs.length === 0) { if (fileIDs.length === 0) {
ctx.stderr.write("Scanning for missing thumbnails...\n"); ctx.log("info", "Scanning for missing thumbnails...");
const missing = await listMissingThumbnails(lib, client, (msg) => { const missing = await listMissingThumbnails(
if (!opts.json) ctx.stderr.write(msg + "\n"); lib,
}); client,
(msg, level) => ctx.log(level ?? "info", msg),
);
fileIDs = missing.map((m) => m.fileID); fileIDs = missing.map((m) => m.fileID);
if (fileIDs.length === 0) { if (fileIDs.length === 0) {
ctx.stderr.write("No missing thumbnails found.\n"); ctx.log("info", "No missing thumbnails found.");
return 0; return 0;
} }
ctx.stderr.write(`Found ${fileIDs.length} file(s) to fix.\n`); ctx.log("info", `Found ${fileIDs.length} file(s) to fix.`);
} }
const results = await fixMissingThumbnails( const results = await fixMissingThumbnails(
lib, lib,
client, client,
fileIDs, fileIDs,
(msg) => { (msg, level) => ctx.log(level ?? "info", msg),
if (!opts.json) ctx.stderr.write(msg + "\n");
},
); );
const failed = results.filter((r) => r.status === "failed").length;
if (opts.json) { if (opts.json) {
ctx.stdout.write(JSON.stringify(results, null, 2) + "\n"); ctx.stdout.write(JSON.stringify(results, null, 2) + "\n");
} else { } else {
@@ -567,30 +598,30 @@ export const fixMissingThumbnailsCommand = async (
const skipped = results.filter( const skipped = results.filter(
(r) => r.status === "skipped", (r) => r.status === "skipped",
).length; ).length;
const failed = results.filter((r) => r.status === "failed").length; ctx.log("info", `\n--- Done ---`);
ctx.stderr.write(`\n--- Done ---\n`); ctx.log("info", ` Fixed: ${fixed}`);
ctx.stderr.write(` Fixed: ${fixed}\n`); ctx.log("info", ` Skipped: ${skipped}`);
ctx.stderr.write(` Skipped: ${skipped}\n`); ctx.log("info", ` Failed: ${failed}`);
ctx.stderr.write(` Failed: ${failed}\n`);
if (skipped > 0) { if (skipped > 0) {
ctx.stderr.write("\nSkipped:\n"); ctx.log("info", "\nSkipped:");
for (const r of results.filter((r) => r.status === "skipped")) { for (const r of results.filter((r) => r.status === "skipped")) {
ctx.stderr.write( ctx.log("info", ` ${r.fileID}\t${r.title}\t${r.reason}`);
` ${r.fileID}\t${r.title}\t${r.reason}\n`,
);
} }
} }
if (failed > 0) { if (failed > 0) {
ctx.stderr.write("\nFailed files:\n"); ctx.log("error", "\nFailed files:");
for (const r of results.filter((r) => r.status === "failed")) { for (const r of results.filter((r) => r.status === "failed")) {
ctx.stderr.write( ctx.log("error", ` ${r.fileID}\t${r.title}\t${r.reason}`);
` ${r.fileID}\t${r.title}\t${r.reason}\n`,
);
} }
} }
} }
return results.some((r) => r.status === "failed") ? 1 : 0; if (failed === 0) return 0;
ctx.log(
"fatal",
`quak: ${failed} file(s) failed; see the "Failed files:" list, or the JSON with --json`,
);
return 1;
} finally { } finally {
await lib.close(); await lib.close();
} }
+99
View File
@@ -0,0 +1,99 @@
// The CLI's log level, and the one function the commands write stderr lines
// through.
//
// Every line quak prints on stderr is a log line at one of six levels, from
// the quietest: fatal, error, warning, info, debug, trace. A run prints the
// lines at its own level and at every quieter one. Its level is info unless
// `-v` or `-q` asks for another (see "CLI surface" in the README). Every
// command that exits non-zero logs a fatal line saying why, so even `-q`, which
// prints only fatal lines, never leaves a non-zero exit unexplained. An error
// line reports something that failed, such as a file or an album, and a
// warning something that did not fail but needs the user's attention. stdout
// carries only a command's output and is not a log.
//
// The library itself writes no lines: its layers report through callbacks, and
// the CLI logs what they report. `logRequests` gives the API client options
// that log each request and each retry, and `logRefresh` the library's
// `onProgress` that logs what a refresh and an ML data fetch report.
import type { ApiClientOptions } from "./api/client.js";
import { describeError } from "./errors.js";
import type { RefreshEvent } from "./library/index.js";
import type { RetryOptions } from "./retry.js";
const LEVELS = ["fatal", "error", "warning", "info", "debug", "trace"] as const;
export type LogLevel = (typeof LEVELS)[number];
// Writes `line` on stderr when the run's level prints lines at `level`.
export type Log = (level: LogLevel, line: string) => void;
// The level asked for by `-v` given `verbose` times, or by `-q`: info without
// either, debug for `-v`, trace for `-vv` and more, fatal for `-q`.
// `bin/quak.ts` refuses `-q` and `-v` together.
export const logLevel = (verbose: number, quiet: boolean): LogLevel => {
if (quiet) return "fatal";
if (verbose === 0) return "info";
return verbose === 1 ? "debug" : "trace";
};
// The log that writes each line at `level` or quieter to `stderr`, ending it
// with a newline.
export const stderrLog =
(stderr: { write(text: string): unknown }, level: LogLevel): Log =>
(lineLevel, line) => {
if (LEVELS.indexOf(lineLevel) <= LEVELS.indexOf(level)) {
stderr.write(line + "\n");
}
};
// API client options that log each request at trace, with the status of its
// response or the error it failed with, and each retry at debug, with why and
// how long it waits. `retry` is the retry policy, the default when not given.
export const logRequests = (
log: Log,
retry?: RetryOptions,
): ApiClientOptions => ({
retry: {
...retry,
onRetry: (err, attempt, delayMs) =>
log(
"debug",
`Attempt ${attempt} failed, retrying in ${Math.round(delayMs)} ms: ${describeError(err)}`,
),
},
onRequest: ({ method, url, status, error }) =>
log(
"trace",
`${method} ${url}: ${error === undefined ? `HTTP ${status}` : describeError(error)}`,
),
});
// How a log line names each operation the library reports the start and end
// of.
const OPERATION_NAMES: Record<RefreshEvent["operation"], string> = {
refresh: "Refresh",
fetchMLData: "ML data fetch",
precacheThumbnails: "Thumbnail precache",
precacheOriginals: "Original precache",
};
// A library `onProgress` that logs, at info, each collection whose files a
// refresh has fetched, and at debug the start and end of each refresh and ML
// data fetch, with the error of one that failed.
export const logRefresh =
(log: Log) =>
(event: RefreshEvent): void => {
if (event.status === "progress") {
log(
"info",
`Fetched the files of ${event.fetched} of ${event.total} collections`,
);
return;
}
const error = event.error === undefined ? "" : `: ${event.error}`;
log(
"debug",
`${OPERATION_NAMES[event.operation]} ${event.status}${error}`,
);
};
+9 -7
View File
@@ -9,7 +9,7 @@
// `metadata.title`, and issue #52 requires that output stay byte-identical, so // `metadata.title`, and issue #52 requires that output stay byte-identical, so
// the commands shape their output from the raw `EnteFile` through here. // the commands shape their output from the raw `EnteFile` through here.
import { sanitizeFileName } from "./filename.js"; import { defaultFileName } from "./filename.js";
import type { EnteFile, FileType, Microseconds } from "./model/types.js"; import type { EnteFile, FileType, Microseconds } from "./model/types.js";
// One row of `quak files --json`. // One row of `quak files --json`.
@@ -33,11 +33,13 @@ export const fileListRow = (file: EnteFile): FileListRow => ({
export const fileListLine = (file: EnteFile): string => export const fileListLine = (file: EnteFile): string =>
`${file.id}\t${file.metadata.fileType}\t${file.metadata.title}`; `${file.id}\t${file.metadata.fileType}\t${file.metadata.title}`;
// Default output path for `quak get` when `--out` is not given. The title comes // Default output path for `quak get` when `--out` is not given, made from the
// from the server, so it is sanitized; `--out` is the user's and is used as is. // title (see `defaultFileName`), with `ext` in place of its extension for a
export const originalName = (file: EnteFile): string => // live photo's image or video; `--out` is the user's and is used as is.
sanitizeFileName(file.metadata.title, `file-${file.id}`); export const originalName = (file: EnteFile, ext?: string): string =>
defaultFileName(file.metadata.title, file.id, { ext });
// Default output path for `quak get-thumb` when `--out` is not given. // Default output path for `quak get-thumb` when `--out` is not given: `thumb_`
// and the title.
export const thumbnailName = (file: EnteFile): string => export const thumbnailName = (file: EnteFile): string =>
`thumb_${originalName(file)}`; defaultFileName(file.metadata.title, file.id, { prefix: "thumb_" });
+41 -19
View File
@@ -1,50 +1,72 @@
// How the CLI's read commands obtain current data. // How the CLI's read commands obtain current data.
// //
// `collections`, `files --collection`, `get`, and `get-thumb` must answer for // `collections`, `files`, `get`, and `get-thumb` must answer for
// the account's state at the moment the command runs, not for whatever the // the account's state at the moment the command runs, not for whatever the
// local cache last happened to hold (owner amendment, issue #36). Each helper // local cache last happened to hold (owner amendment, issue #36). `files`,
// therefore forces a server round-trip through `Library.fresh()` and only then // `get` and `get-thumb` therefore force a server round-trip through
// reads — so a collection, file, or metadata change made elsewhere is visible. // `Library.fresh()` and only then read — so a collection, file, or metadata
// change made elsewhere is visible. `collections` asks the library for the
// collection list alone through `Library.freshCollections()` (issue #210): a
// refresh would also fetch the files of every collection, which on a large
// account takes long and which `collections` does not print.
// //
// `collections` and `files` also list in the library's own enumeration order — // `collections` lists in the server's order and `files` in the library's own
// `listCollections()`/`listFiles()`, the order the pre-library CLI printed — // enumeration order (`listFiles()`), the orders the pre-library CLI printed,
// rather than the `albums`/`photos` projection's newest-first order, which // rather than the `albums`/`photos` projection's newest-first order, which
// re-sorts the rows. The field values still come from each record's raw // re-sorts the rows. The field values still come from each record's raw
// metadata via `cli-output.ts`. // metadata via `cli-output.ts`. `files` without a collection ID lists every
// file in the account once (issue #211): the library keeps a record of a file
// for each collection that holds it, and only the first collection's record is
// listed.
import type { Collection, EnteFile } from "./model/types.js"; import type { Collection, EnteFile } from "./model/types.js";
import type { Photo, PhotosAPI } from "./library/index.js"; import type { Photo, PhotosAPI } from "./library/index.js";
// The slice of `Library` these helpers read. `Library` satisfies it // The slice of `Library` these helpers read. `Library` satisfies it
// structurally; a test can drive them with a stand-in that records the // structurally; a test can drive them with a stand-in that records the
// `fresh()` call and serves records in a known enumeration order. // `fresh()` and `freshCollections()` calls and serves records in a known
// enumeration order.
export interface FreshReadLibrary { export interface FreshReadLibrary {
fresh(): Promise<unknown>; fresh(): Promise<unknown>;
listCollections(): Collection[]; freshCollections(): Promise<Collection[]>;
getCollection(id: number): Collection | undefined; getCollection(id: number): Collection | undefined;
listCollections(): Collection[];
listFiles(collectionID: number): EnteFile[]; listFiles(collectionID: number): EnteFile[];
getFileByID(fileID: number): EnteFile | undefined; getFileByID(fileID: number): EnteFile | undefined;
photos: Pick<PhotosAPI, "byID">; photos: Pick<PhotosAPI, "byID">;
} }
// Every live collection, current as of a forced refresh, in enumeration order. // Every live collection, current as of one collections request, in the
export const freshCollections = async ( // server's order.
export const freshCollections = (
lib: FreshReadLibrary, lib: FreshReadLibrary,
): Promise<Collection[]> => { ): Promise<Collection[]> => lib.freshCollections();
await lib.fresh();
return lib.listCollections();
};
// The files of one collection, current as of a forced refresh, in enumeration // The files of one collection, current as of a forced refresh, in enumeration
// order. `undefined` (not an empty list) when the collection does not exist, so // order. `undefined` (not an empty list) when the collection does not exist, so
// the caller can tell "no such collection" from "an empty collection". // the caller can tell "no such collection" from "an empty collection". Without
// a collection ID, every file in the account, each once, as held by the first
// collection that holds it.
export const freshFiles = async ( export const freshFiles = async (
lib: FreshReadLibrary, lib: FreshReadLibrary,
collectionID: number, collectionID?: number,
): Promise<EnteFile[] | undefined> => { ): Promise<EnteFile[] | undefined> => {
await lib.fresh(); await lib.fresh();
if (!lib.getCollection(collectionID)) return undefined; if (collectionID !== undefined) {
return lib.listFiles(collectionID); return lib.getCollection(collectionID)
? lib.listFiles(collectionID)
: undefined;
}
const listed = new Set<number>();
const files: EnteFile[] = [];
for (const collection of lib.listCollections()) {
for (const file of lib.listFiles(collection.id)) {
if (listed.has(file.id)) continue;
listed.add(file.id);
files.push(file);
}
}
return files;
}; };
// One file, current as of a forced refresh, resolved to both its content // One file, current as of a forced refresh, resolved to both its content
+12 -9
View File
@@ -2,21 +2,23 @@
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 type { Log } from "./cli-log.js";
import { describeError } from "./errors.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 logged as one fatal `quak: MESSAGE` line,
// the stack trace, and exits 1. MESSAGE is the error as `describeError` gives // without the stack trace, and exits 1. MESSAGE is the error as
// it, so it carries the reason Node's `fetch` puts on `cause`. A 401 from the // `describeError` gives it, so it carries the reason Node's `fetch` puts on
// server means it no longer accepts the saved session: that prints one line // `cause`. A 401 from the server means it no longer accepts the saved session:
// saying to log in again and exits 3, as a missing or corrupt session file // that logs one fatal line saying to log in again and exits 3, as a missing or
// does. // corrupt session file does.
export const run = async ( export const run = async (
command: Promise<number>, command: Promise<number>,
stdout: Writable, stdout: Writable,
stderr: Writable, stderr: Writable,
log: Log,
exit: (code: number) => void, exit: (code: number) => void,
): Promise<void> => { ): Promise<void> => {
let code: number; let code: number;
@@ -24,12 +26,13 @@ export const run = async (
code = await command; code = await command;
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 401) { if (err instanceof ApiError && err.status === 401) {
stderr.write( log(
`quak: the saved session is no longer valid; run "quak login"\n`, "fatal",
`quak: the saved session is no longer valid; run "quak login"`,
); );
code = 3; code = 3;
} else { } else {
stderr.write(`quak: ${describeError(err)}\n`); log("fatal", `quak: ${describeError(err)}`);
code = 1; code = 1;
} }
} }
+8 -4
View File
@@ -373,13 +373,17 @@ 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. One request; the // to a batch of files, each decrypted with its own key, and for each file
// library batches at `MLDATA_BATCH_SIZE` and schedules each batch through // whose ML data did not decrypt, gunzip or parse, why not. One request;
// its metadata request pool. // the library batches at `MLDATA_BATCH_SIZE` and schedules each batch
// through its metadata request pool.
async fetchMLData(args: { async fetchMLData(args: {
fileIDs: number[]; fileIDs: number[];
fileKeys: Map<number, Uint8Array>; fileKeys: Map<number, Uint8Array>;
}): Promise<Map<number, MLData>> { }): Promise<{
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);
} }
+38 -17
View File
@@ -16,7 +16,7 @@ import {
streamTagFinal, streamTagFinal,
} from "../crypto/index.js"; } from "../crypto/index.js";
import { TruncatedStreamError } from "../errors.js"; import { TruncatedStreamError } from "../errors.js";
import { safeExtension, sanitizeFileName, withExtension } from "../filename.js"; import { defaultFileName, safeExtension, withExtension } from "../filename.js";
import { withRetry } from "../retry.js"; import { withRetry } from "../retry.js";
import type { ApiClient } from "../api/client.js"; import type { ApiClient } from "../api/client.js";
import type { EnteFile } from "../model/types.js"; import type { EnteFile } from "../model/types.js";
@@ -199,11 +199,13 @@ 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` and the backup copy's // `.quak-<pid>-<random>.tmp`, the backup copy's
// `.quak-backup-<name>-<pid>-<random>.tmp`. Only files whose process is no // `.quak-backup-<name>-<pid>-<random>.tmp`, and the image and video of a live
// longer running are removed, so another process writing into the same // photo downloaded to replace a stored one, `.quak-<pid>-<random>.<ext>` (see
// directory keeps its own. A reused process ID can only keep a leftover a while // `replaceOriginal`). Only files whose process is no longer running are
// longer, never remove a live one. // removed, so another process writing into the same directory keeps its own. A
// 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 {
@@ -212,7 +214,9 @@ 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]*\.tmp$/.exec(name); const match = /^\.quak-(?:.*-)?(\d+)-[0-9a-z]*\.[0-9A-Za-z]+$/.exec(
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 });
} }
@@ -361,10 +365,14 @@ const openPart = async (
// name starts with `image` as the image and the one whose name starts with // name starts with `image` as the image and the one whose name starts with
// `video` as the video, and refuses a ZIP holding a second of either. It is // `video` as the video, and refuses a ZIP holding a second of either. It is
// written unpacked: each part is named `destination` with the extension // written unpacked: each part is named `destination` with the extension
// replaced by its own entry's, and the two must differ ignoring case. When the // replaced by its own entry's, and the two must differ ignoring case. When
// file records a hash, `<imageHash>:<videoHash>` must match it, each over that // `defaultName` is set, `destination` is the name `defaultFileName` made from
// part's own bytes. Only then are the image, then the video, renamed into // the title rather than a path the caller gave, and each part is named as
// place; on any failure neither is stored. // `defaultFileName` names it with its own extension, so that the name is cut
// short to fit with that extension. When the file records a hash,
// `<imageHash>:<videoHash>` must match it, each over that part's own bytes.
// Only then are the image, then the video, renamed into place; on any failure
// neither is stored.
// //
// The ZIP is chosen by its uploader and may expand enormously, so each part is // The ZIP is chosen by its uploader and may expand enormously, so each part is
// written as it decompresses and never held, and the ZIP is refused once the // written as it decompresses and never held, and the ZIP is refused once the
@@ -384,6 +392,7 @@ const decryptLivePhoto = async (
key: Uint8Array, key: Uint8Array,
onProgress: ProgressCallback | undefined, onProgress: ProgressCallback | undefined,
file: EnteFile, file: EnteFile,
defaultName: boolean,
): Promise<DownloadResult> => { ): Promise<DownloadResult> => {
const sliceSize = 4096; const sliceSize = 4096;
const dir = dirname(destination); const dir = dirname(destination);
@@ -482,8 +491,12 @@ const decryptLivePhoto = async (
`live photo's image and video have the same extension, ${video.ext}`, `live photo's image and video have the same extension, ${video.ext}`,
); );
} }
const path = withExtension(destination, image.ext); const partPath = (ext: string): string =>
const videoPath = withExtension(destination, video.ext); defaultName
? defaultFileName(file.metadata.title, file.id, { ext })
: withExtension(destination, ext);
const path = partPath(image.ext);
const videoPath = partPath(video.ext);
for (const part of parts) { for (const part of parts) {
await part.handle.sync(); await part.handle.sync();
await part.handle.close(); await part.handle.close();
@@ -527,6 +540,9 @@ const decryptLivePhoto = async (
// and then removes its own temp file, and only the attempt that reaches // and then removes its own temp file, and only the attempt that reaches
// TAG_FINAL renames one into place, so a download that needed three tries still // TAG_FINAL renames one into place, so a download that needed three tries still
// performs exactly one rename over the destination. // performs exactly one rename over the destination.
//
// `defaultName` says `destination` is the name `defaultFileName` made, not a
// path the caller gave (see `decryptLivePhoto`).
const fetchAndDecrypt = async ( const fetchAndDecrypt = async (
api: ApiClient, api: ApiClient,
openStream: () => Promise<ReadableStream<Uint8Array>>, openStream: () => Promise<ReadableStream<Uint8Array>>,
@@ -535,6 +551,7 @@ const fetchAndDecrypt = async (
destination: string, destination: string,
onProgress?: ProgressCallback, onProgress?: ProgressCallback,
original?: EnteFile, original?: EnteFile,
defaultName = false,
): Promise<DownloadResult> => ): Promise<DownloadResult> =>
withRetry(async () => { withRetry(async () => {
const stream = await openStream(); const stream = await openStream();
@@ -547,6 +564,7 @@ const fetchAndDecrypt = async (
key, key,
onProgress, onProgress,
original, original,
defaultName,
); );
} }
const bytesWritten = await decryptToTemp( const bytesWritten = await decryptToTemp(
@@ -578,10 +596,11 @@ export const downloadFile = async (
outPath?: string, outPath?: string,
onProgress?: ProgressCallback, onProgress?: ProgressCallback,
): Promise<DownloadResult> => { ): Promise<DownloadResult> => {
// `outPath` is the caller's and is used as is; the title is the server's // `outPath` is the caller's and is used as is. Without it, the file is
// and is sanitized so it can only name a file in the current directory. // written in the current directory under the name `defaultFileName` makes
// from the title, which is the server's.
const resolvedPath = const resolvedPath =
outPath ?? sanitizeFileName(file.metadata.title, `file-${file.id}`); outPath ?? defaultFileName(file.metadata.title, file.id);
const header = fromBase64(file.file.decryptionHeader); const header = fromBase64(file.file.decryptionHeader);
return fetchAndDecrypt( return fetchAndDecrypt(
api, api,
@@ -591,6 +610,7 @@ export const downloadFile = async (
resolvedPath, resolvedPath,
onProgress, onProgress,
file, file,
outPath === undefined,
); );
}; };
@@ -600,9 +620,10 @@ export const downloadThumbnail = async (
outPath?: string, outPath?: string,
onProgress?: ProgressCallback, onProgress?: ProgressCallback,
): Promise<DownloadResult> => { ): Promise<DownloadResult> => {
// As in `downloadFile`, with `thumb_` before the title.
const resolvedPath = const resolvedPath =
outPath ?? outPath ??
`thumb_${sanitizeFileName(file.metadata.title, `file-${file.id}`)}`; defaultFileName(file.metadata.title, file.id, { prefix: "thumb_" });
const header = fromBase64(file.thumbnail.decryptionHeader); const header = fromBase64(file.thumbnail.decryptionHeader);
return fetchAndDecrypt( return fetchAndDecrypt(
api, api,
+74 -4
View File
@@ -4,7 +4,10 @@
// 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. // Windows device name. Such a name can still be too long for a file name, and
// `shortenToBytes` or `shortenKeepingExtension` cuts one short.
// `defaultFileName` does both for the name a file is saved under when the
// caller gives no path.
// //
// 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.
@@ -29,13 +32,80 @@ export const sanitizeFileName = (name: string, fallback: string): string => {
return RESERVED_DEVICE_NAME.test(cleaned) ? `_${cleaned}` : cleaned; return RESERVED_DEVICE_NAME.test(cleaned) ? `_${cleaned}` : cleaned;
}; };
// The extension of `title` (".jpg"), or ".bin" when it has none or it holds // The extension of `title` (".jpg"), or ".bin" when it has none, it holds
// anything but letters and digits. // anything but letters and digits, or it is more than 16 of them long.
export const safeExtension = (title: string): string => { export const safeExtension = (title: string): string => {
const ext = extname(title); const ext = extname(title);
return /^\.[A-Za-z0-9]+$/.test(ext) ? ext : ".bin"; return /^\.[A-Za-z0-9]{1,16}$/.test(ext) ? ext : ".bin";
}; };
// `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);
};
// `name` cut short to fit in `maxBytes` bytes with `insert` put right before
// its extension (`IMG_0001 (12).jpg`), each cut made as `shortenToBytes`
// makes it. The part before the extension is cut first, so the extension is
// kept whenever it fits, but that part always keeps its first character:
// when the extension leaves no room for `insert` and that character, the
// extension is cut short too, and the part before it keeps the bytes that cut
// leaves over. So a name that does not start with a dot never comes back
// starting with one, which would hide it. `insert` is never cut. With no
// `insert`, a name that fits comes back unchanged.
export const shortenKeepingExtension = (
name: string,
maxBytes: number,
insert = "",
): string => {
const fullExt = extname(name);
const stem = name.slice(0, name.length - fullExt.length);
const first = Array.from(stem)[0] ?? "";
const ext = shortenToBytes(
fullExt,
maxBytes - Buffer.byteLength(insert + first),
);
const room = maxBytes - Buffer.byteLength(insert + ext);
return shortenToBytes(stem, room) + insert + ext;
};
// The name a file is saved under in the working directory when the caller
// gives no path (`quak get` without `--out`, `downloadFile` without
// `outPath`): `prefix` (`thumb_` for a thumbnail), then the file's title with
// unsafe characters replaced, or `file-<id>` when the title is empty, cut
// short so that the whole fits in a file name, keeping the title's extension
// whenever it fits. `ext` (".mov"), when given, takes the place of the
// title's extension before the name is cut short; a live photo's image and
// video are each named with their own. The cut can leave a device name
// (`CONSOLE.<long extension>` becomes `CON.<long extension>`); that name gets
// a leading `_`, as `sanitizeFileName` gives one, and is cut a byte shorter to
// leave room for it.
export const defaultFileName = (
title: string,
fileID: number,
opts: { prefix?: string; ext?: string } = {},
): string => {
const { prefix = "", ext } = opts;
const safe = sanitizeFileName(title, `file-${fileID}`);
const name = ext === undefined ? safe : withExtension(safe, ext);
const room = MAX_NAME_BYTES - Buffer.byteLength(prefix);
const cut = prefix + shortenKeepingExtension(name, room);
if (!RESERVED_DEVICE_NAME.test(cut)) return cut;
return `_${prefix}${shortenKeepingExtension(name, room - 1)}`;
};
+116 -2
View File
@@ -13,6 +13,8 @@
// 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
@@ -27,9 +29,10 @@
// 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. // fetched again. An original stored there whose bytes do not match its content
// hash is replaced only by a download that does (`replaceOriginal`).
import { existsSync, readFileSync, statSync } from "node:fs"; import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
import { import {
chmod, chmod,
copyFile, copyFile,
@@ -44,6 +47,12 @@ 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,
@@ -349,6 +358,26 @@ 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
@@ -405,6 +434,62 @@ 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;
@@ -543,6 +628,35 @@ 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);
} }
+107 -15
View File
@@ -18,6 +18,16 @@
// the caller (the default reads stay silent and serve the last good copy). The // the caller (the default reads stay silent and serve the last good copy). The
// default methods and the background loop are unchanged. // default methods and the background loop are unchanged.
// //
// `freshCollections()` is the fresh read of the collection list alone (issue
// #210): one collections request, no collection's files, and nothing stored,
// for a caller such as `quak collections` that needs no more. A caller that
// asks for each refresh it needs can also open the library with
// `refreshOnOpen: false`, so that `open()` starts none.
//
// A refresh reports through `onProgress` each collection whose files it has
// fetched, so a large account, whose first refresh fetches the files of every
// collection, shows progress.
//
// A refresh stages all of its network work first and only mutates the store // A refresh stages all of its network work first and only mutates the store
// once every fetch has succeeded. A refresh that fails partway therefore never // once every fetch has succeeded. A refresh that fails partway therefore never
// becomes visible to reads: the last good snapshot stays in place, and the // becomes visible to reads: the last good snapshot stays in place, and the
@@ -89,6 +99,7 @@ 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";
@@ -138,12 +149,16 @@ 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. Optional: a client without it simply disables ML fetching, leaving // files, and for each file whose ML data did not decrypt, gunzip or parse,
// the metadata refresh untouched. // why not. Optional: a client without it simply disables ML fetching,
// leaving the metadata refresh untouched.
fetchMLData?(args: { fetchMLData?(args: {
fileIDs: number[]; fileIDs: number[];
fileKeys: Map<number, Uint8Array>; fileKeys: Map<number, Uint8Array>;
}): Promise<Map<number, MLData>>; }): Promise<{
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
@@ -154,7 +169,11 @@ 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. The precache fills // an ML "done" reports how many payloads it stored and, in `mlDataErrors`,
// each file whose ML data did not decrypt, gunzip or parse, with the reason.
// In between, a refresh fires "progress" after each collection whose files it
// fetched: `fetched` is how many collections it has fetched the files of so
// far, `total` how many it will. The precache fills
// ("precacheThumbnails"/"precacheOriginals", #48) fire "started"/"done" around // ("precacheThumbnails"/"precacheOriginals", #48) fire "started"/"done" around
// each sweep that has work, "done" reporting the count newly cached. // each sweep that has work, "done" reporting the count newly cached.
export interface RefreshEvent { export interface RefreshEvent {
@@ -163,9 +182,11 @@ export interface RefreshEvent {
| "fetchMLData" | "fetchMLData"
| "precacheThumbnails" | "precacheThumbnails"
| "precacheOriginals"; | "precacheOriginals";
status: "started" | "done" | "failed"; status: "started" | "progress" | "done" | "failed";
error?: string; error?: string;
fetched?: number; fetched?: number;
total?: number;
mlDataErrors?: { fileID: number; error: string }[];
} }
export type RefreshProgressCallback = (event: RefreshEvent) => void; export type RefreshProgressCallback = (event: RefreshEvent) => void;
@@ -184,6 +205,10 @@ export interface LibraryOptions {
// `Infinity` starts none: the library then refreshes only as it opens and // `Infinity` starts none: the library then refreshes only as it opens and
// when `fresh()` or `backup()` asks. // when `fresh()` or `backup()` asks.
refreshIntervalSeconds?: number; refreshIntervalSeconds?: number;
// Whether `open()` starts a refresh. Defaults to true. With false, `open()`
// returns at once onto what the cache holds, empty or not, and a refresh
// starts only when `fresh()` or `backup()` asks or the timer fires.
refreshOnOpen?: boolean;
onProgress?: RefreshProgressCallback; onProgress?: RefreshProgressCallback;
// The bounded request pools (issue #45), shared by the ML-data fetch (the // The bounded request pools (issue #45), shared by the ML-data fetch (the
// metadata pool) and the content cache. Defaults to a fresh set at the // metadata pool) and the content cache. Defaults to a fresh set at the
@@ -225,6 +250,11 @@ 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;
@@ -292,6 +322,12 @@ 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
@@ -363,7 +399,8 @@ export class Library {
// then opens empty with the failure recorded in `status()`. With an // then opens empty with the failure recorded in `status()`. With an
// existing cache the first refresh runs in the background and `open()` // existing cache the first refresh runs in the background and `open()`
// returns as soon as the cached data is ready — an unreachable server does // returns as soon as the cached data is ready — an unreachable server does
// not block opening. // not block opening. With `refreshOnOpen: false` there is no first
// refresh: `open()` returns onto the cache as it is.
static async open(opts: LibraryOptions): Promise<Library> { static async open(opts: LibraryOptions): Promise<Library> {
const { userID } = opts.client.whoami(); const { userID } = opts.client.whoami();
const cacheDirectory = const cacheDirectory =
@@ -466,7 +503,10 @@ export class Library {
// refresh. // refresh.
precache?.start(); precache?.start();
if (store.loadedFromDisk) { if (opts.refreshOnOpen === false) {
// The caller asks for each refresh it needs.
lib.scheduleNext();
} else if (store.loadedFromDisk) {
// An existing copy already answers reads; refresh in the background // An existing copy already answers reads; refresh in the background
// and start the interval once that first cycle settles. // and start the interval once that first cycle settles.
void lib.runRefresh().then(() => lib.scheduleNext()); void lib.runRefresh().then(() => lib.scheduleNext());
@@ -542,6 +582,7 @@ 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,
@@ -569,6 +610,21 @@ export class Library {
}; };
} }
// The fresh read of the collection list alone (issue #210): every live
// collection that decrypts, in the server's order, from one collections
// request. Unlike a refresh, it fetches no collection's files, which on a
// large account take long. Nothing is stored: a refresh fetches a
// collection's files only when the collection is new or has changed since
// the stored copy, so a collection stored here without its files would
// never have them fetched.
async freshCollections(): Promise<Collection[]> {
if (this.closed) throw new Error("the library is closed");
const { collections } = await this.client.collectionsSince({
sinceTime: 0,
});
return collections;
}
// 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 the
@@ -606,10 +662,13 @@ export class Library {
undecryptable: () => this.store.listUndecryptable(), undecryptable: () => this.store.listUndecryptable(),
original: (fileID, destination) => original: (fileID, destination) =>
cache!.backupOriginal(fileID, destination), cache!.backupOriginal(fileID, destination),
fetchOriginal: (fileID, destination) =>
cache!.fetchOriginal(fileID, destination),
thumbnail: (fileID) => cache!.thumbnail(fileID), thumbnail: (fileID) => cache!.thumbnail(fileID),
contentConcurrency: this.pools.content.concurrency, contentConcurrency: this.pools.content.concurrency,
fetchMLData: () => this.fetchMLDataNow(), fetchMLData: () => this.fetchMLDataNow(),
mlData: (fileID) => this.mldata.forFile({ fileID }), mlData: (fileID) => this.mldata.forFile({ fileID }),
mlDataError: (fileID) => this.mlDataErrors.get(fileID),
}, },
{ ...opts, downloadDirectory }, { ...opts, downloadDirectory },
); );
@@ -719,18 +778,28 @@ export class Library {
// Stage per-collection file diffs. A collection's files are // Stage per-collection file diffs. A collection's files are
// re-enumerated only when its updationTime has advanced past the cached // re-enumerated only when its updationTime has advanced past the cached
// copy; an unchanged album's file list cannot have changed. New // copy; an unchanged album's file list cannot have changed. New
// collections enumerate from the beginning of time. // collections enumerate from the beginning of time. Each collection
// whose files are fetched is reported, with how many there are to
// fetch.
const changedCollections = page.collections.filter((collection) => {
const known = this.store.getCollection(collection.id);
return !known || collection.updationTime > known.updationTime;
});
const filePages: { collectionID: number; page: FilesPage }[] = []; const filePages: { collectionID: number; page: FilesPage }[] = [];
for (const collection of page.collections) { for (const collection of changedCollections) {
const known = this.store.getCollection(collection.id); const known = this.store.getCollection(collection.id);
if (known && collection.updationTime <= known.updationTime)
continue;
const filePage = await this.client.filesSince({ const filePage = await this.client.filesSince({
collectionID: collection.id, collectionID: collection.id,
collectionKey: collection.key, collectionKey: collection.key,
sinceTime: known ? known.updationTime : 0, sinceTime: known ? known.updationTime : 0,
}); });
filePages.push({ collectionID: collection.id, page: filePage }); filePages.push({ collectionID: collection.id, page: filePage });
this.emit({
operation: "refresh",
status: "progress",
fetched: filePages.length,
total: changedCollections.length,
});
} }
// Network work done; commit to the store and persist only if something // Network work done; commit to the store and persist only if something
@@ -836,8 +905,11 @@ 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. A // advanced), through the metadata pool, and update the CLIP index. For a
// failure is reported through `status()` and `onProgress`, then thrown. // file whose ML data does not decrypt, gunzip or parse, it gathers the
// 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
@@ -847,7 +919,14 @@ export class Library {
const files = this.uniqueFiles(); const files = this.uniqueFiles();
const needed = mldata.neededFor(files); const needed = mldata.neededFor(files);
if (needed.length === 0) return; // This pass's reasons, put in `this.mlDataErrors` when it ends without
// 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 {
@@ -862,20 +941,25 @@ 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 = await this.pools.metadata.run( const { payloads, errors } = 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);
@@ -885,6 +969,14 @@ 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.
+36 -11
View File
@@ -2,9 +2,14 @@ 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 {
MAX_NAME_BYTES,
sanitizeFileName,
shortenToBytes,
} from "./filename.js";
import { import {
fetchMLDataBatch, fetchMLDataBatch,
MLDATA_BATCH_SIZE, MLDATA_BATCH_SIZE,
@@ -12,7 +17,10 @@ import {
} from "./mldata-fetch.js"; } from "./mldata-fetch.js";
import type { EnteFile } from "./model/types.js"; import type { EnteFile } from "./model/types.js";
export type ProgressCallback = (message: string) => void; // Reports one line of the dump's progress. `level` is "error" when the line
// reports ML data that could not be read, and is left out for every other
// line.
export type ProgressCallback = (message: string, level?: "error") => void;
export interface MetadataBackupOptions { export interface MetadataBackupOptions {
exif?: boolean; exif?: boolean;
@@ -91,14 +99,15 @@ 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 ML data requests failed; their files are still // first. Returns how many files have no ML data because their ML data request
// 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<{ failedMLBatches: number }> => { ): Promise<{ filesWithMLDataError: number }> => {
const log = opts?.onProgress ?? (() => {}); const log = opts?.onProgress ?? (() => {});
const wantExif = opts?.exif ?? false; const wantExif = opts?.exif ?? false;
@@ -125,7 +134,15 @@ export const runMetadataBackup = async (
const col = lib.getCollection(album.collectionID); const col = lib.getCollection(album.collectionID);
if (!col) continue; if (!col) continue;
const dirName = `${col.id}-${sanitizeFileName(col.name, "unnamed")}`; // The album's ID keeps the folders apart, so the name after it can be
// cut short to fit in a file name.
const prefix = `${col.id}-`;
const dirName =
prefix +
shortenToBytes(
sanitizeFileName(col.name, "unnamed"),
MAX_NAME_BYTES - prefix.length,
);
const colDir = join(outDir, "collections", dirName); const colDir = join(outDir, "collections", dirName);
mkdirSync(colDir, { recursive: true }); mkdirSync(colDir, { recursive: true });
@@ -164,26 +181,34 @@ 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. // get the reason in `mlDataError` and the other batches go on. So does a
// 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 result = await fetchMLDataBatch( const { payloads, errors } = await fetchMLDataBatch(
client.getApiClient(), client.getApiClient(),
batch, batch,
fileKeys, fileKeys,
); );
for (const [id, payload] of result) mlDataMap.set(id, payload); for (const [id, payload] of payloads) 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}`,
"error",
);
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}`,
"error",
); );
for (const id of batch) mlDataErrors.set(id, reason); for (const id of batch) mlDataErrors.set(id, reason);
} }
@@ -229,5 +254,5 @@ export const runMetadataBackup = async (
} }
log("Metadata backup complete."); log("Metadata backup complete.");
return { failedMLBatches }; return { filesWithMLDataError: mlDataErrors.size };
}; };
+33 -24
View File
@@ -28,43 +28,52 @@ interface RawRemoteFileData {
updatedAt?: number; updatedAt?: number;
} }
// Decrypt one entry with its file key and gunzip the JSON payload. Returns // Decrypt one entry with its file key and gunzip the JSON payload. Throws why
// undefined when the key is unknown or the entry does not decrypt/parse, so one // when the entry does not decrypt, gunzip or parse, or parses to something
// corrupt file never fails a whole batch. // other than a JSON object, such as `null`, a number or an array.
const decodeEntry = ( const decodeEntry = (entry: RawRemoteFileData, key: Uint8Array): MLData => {
entry: RawRemoteFileData, const decrypted = decryptBlob(
key: Uint8Array | undefined, fromBase64(entry.encryptedData),
): MLData | undefined => { fromBase64(entry.decryptionHeader),
if (!key) return undefined; key,
try { );
const decrypted = decryptBlob( const json = gunzipSync(Buffer.from(decrypted)).toString("utf-8");
fromBase64(entry.encryptedData), const payload: unknown = JSON.parse(json);
fromBase64(entry.decryptionHeader), if (
key, typeof payload !== "object" ||
); payload === null ||
const json = gunzipSync(Buffer.from(decrypted)).toString("utf-8"); Array.isArray(payload)
return JSON.parse(json) as MLData; ) {
} catch { throw new Error("not a JSON object");
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. // batches and submit each batch to the pool. Returns the payload of each file
// 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<Map<number, MLData>> => { ): Promise<{ payloads: Map<number, MLData>; errors: Map<number, unknown> }> => {
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 result = new Map<number, MLData>(); const payloads = new Map<number, MLData>();
const errors = new Map<number, unknown>();
for (const entry of data ?? []) { for (const entry of data ?? []) {
const payload = decodeEntry(entry, fileKeys.get(entry.fileID)); const key = fileKeys.get(entry.fileID);
if (payload) result.set(entry.fileID, payload); if (!key) continue;
try {
payloads.set(entry.fileID, decodeEntry(entry, key));
} catch (err) {
errors.set(entry.fileID, err);
}
} }
return result; return { payloads, errors };
}; };
+35 -8
View File
@@ -24,6 +24,9 @@ export interface RetryOptions {
sleep?: (ms: number) => Promise<void>; sleep?: (ms: number) => Promise<void>;
// Injected so the jitter is reproducible under test. // Injected so the jitter is reproducible under test.
random?: () => number; random?: () => number;
// Called before each wait with the error that failed attempt number
// `attempt` and how long the wait is, for a caller that logs retries.
onRetry?: (err: unknown, attempt: number, delayMs: number) => void;
} }
export type ResolvedRetryOptions = Required<RetryOptions>; export type ResolvedRetryOptions = Required<RetryOptions>;
@@ -35,6 +38,7 @@ export const DEFAULT_RETRY_OPTIONS: ResolvedRetryOptions = {
sleep: (ms: number): Promise<void> => sleep: (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms)), new Promise((resolve) => setTimeout(resolve, ms)),
random: Math.random, random: Math.random,
onRetry: () => {},
}; };
// For a run nobody is watching, such as `quak backup` from cron. A request that // For a run nobody is watching, such as `quak backup` from cron. A request that
@@ -55,9 +59,18 @@ export const resolveRetryOptions = (
maxDelayMs: opts?.maxDelayMs ?? DEFAULT_RETRY_OPTIONS.maxDelayMs, maxDelayMs: opts?.maxDelayMs ?? DEFAULT_RETRY_OPTIONS.maxDelayMs,
sleep: opts?.sleep ?? DEFAULT_RETRY_OPTIONS.sleep, sleep: opts?.sleep ?? DEFAULT_RETRY_OPTIONS.sleep,
random: opts?.random ?? DEFAULT_RETRY_OPTIONS.random, random: opts?.random ?? DEFAULT_RETRY_OPTIONS.random,
onRetry: opts?.onRetry ?? DEFAULT_RETRY_OPTIONS.onRetry,
}); });
// 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",
@@ -70,6 +83,9 @@ 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
@@ -87,6 +103,12 @@ 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"]);
// 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
@@ -105,7 +127,7 @@ const causeCodes = (err: unknown): { codes: string[]; complete: boolean } => {
return { codes, complete }; return { codes, complete };
}; };
// Does the error's cause chain hold the errno of a transport failure, one of // 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 // `TRANSPORT_CODES`? `isRetryable` retries such an error, and `classify` in
// `src/backup.ts` calls it transient. // `src/backup.ts` calls it transient.
export const hasTransportCode = (err: unknown): boolean => export const hasTransportCode = (err: unknown): boolean =>
@@ -152,7 +174,8 @@ 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. // count. Bun's fetch rejects with a plain `Error` instead, which is
// recognised by its code (see `TRANSPORT_CODES`).
if (err instanceof TypeError) return true; if (err instanceof TypeError) return true;
return hasTransportCode(err); return hasTransportCode(err);
}; };
@@ -170,11 +193,13 @@ 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. A // acted on. A routing errno can be delivered on an established socket. Bun's
// deadline says nothing at all about the server's state. So every errno in the // `ConnectionRefused` and `FailedToOpenSocket` can come from a new connection
// cause chain must be a connect errno: one other errno anywhere in the chain // bun opened to resend a request that already went out on a connection it kept
// is doubt, and doubt is not replayed. A chain longer than the walk is doubt // open. A deadline says nothing at all about the server's state. So every code in the
// too: the links below the limit were never read. // cause chain must be one of `CONNECT_CODES`: one other code anywhere in the
// 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 (
@@ -214,7 +239,9 @@ export const withRetry = async <T>(
// and its status intact. // and its status intact.
throw err; throw err;
} }
await policy.sleep(backoffMs(attempt, policy)); const delayMs = backoffMs(attempt, policy);
policy.onRetry(err, attempt, delayMs);
await policy.sleep(delayMs);
} }
} }
}; };
+6 -1
View File
@@ -49,7 +49,10 @@ export interface ThumbnailFixResult {
reason?: string; reason?: string;
} }
export type ProgressCallback = (message: string) => void; // Reports one line of progress. `level` is "error" when the line reports a
// file that could not be checked or fixed, and is left out for every other
// line.
export type ProgressCallback = (message: string, level?: "error") => void;
// Enumerate every file the library knows about, newest album first, each file // Enumerate every file the library knows about, newest album first, each file
// once, and report those whose server-side thumbnail is missing. "Missing" is // once, and report those whose server-side thumbnail is missing. "Missing" is
@@ -111,6 +114,7 @@ export const listMissingThumbnails = async (
} else { } else {
log( log(
`[${album.name}] Could not check ${photo.title}: ${err instanceof Error ? err.message : String(err)} (not reported as missing)`, `[${album.name}] Could not check ${photo.title}: ${err instanceof Error ? err.message : String(err)} (not reported as missing)`,
"error",
); );
} }
} }
@@ -359,6 +363,7 @@ export const fixMissingThumbnails = async (
} catch (err) { } catch (err) {
log( log(
`[${collectionName}] FAILED ${title}: ${err instanceof Error ? err.message : err}`, `[${collectionName}] FAILED ${title}: ${err instanceof Error ? err.message : err}`,
"error",
); );
results.push({ results.push({
fileID, fileID,
+62
View File
@@ -1222,3 +1222,65 @@ describe("ApiClient non-idempotent requests", () => {
} }
}); });
}); });
// `onRequest` hears of each request the client sends, as its response arrives
// or as it fails. The CLI logs these with `-vv`. A retried request is reported
// once for each attempt.
describe("ApiClient onRequest", () => {
it("reports each request with its status, or with the error it failed with", async () => {
const refused = new TypeError("fetch failed");
const { fetch } = scriptedFetch(
refused,
textResponse("unavailable", 503),
jsonResponse({ collections: [] }),
jsonResponse({}),
streamResponse(new Uint8Array([1, 2, 3])),
);
const requests: unknown[] = [];
const client = new ApiClient({
fetch,
retry: noWait,
onRequest: (request) => requests.push(request),
});
await client.getJSON("/collections/v2", { sinceTime: 0 });
await client.postJSON("/users/logout", {});
await readAll(await client.getFileStream(7));
const collections = "https://api.ente.io/collections/v2?sinceTime=0";
expect(requests).toEqual([
{ method: "GET", url: collections, error: refused },
{ method: "GET", url: collections, status: 503 },
{ method: "GET", url: collections, status: 200 },
{
method: "POST",
url: "https://api.ente.io/users/logout",
status: 200,
},
{
method: "GET",
url: "https://files.ente.io/?fileID=7",
status: 200,
},
]);
});
it("reports a presigned upload without its query, which holds the upload's signature", async () => {
const { fetch } = scriptedFetch(new Response(null, { status: 200 }));
const requests: unknown[] = [];
const client = new ApiClient({
fetch,
retry: noWait,
onRequest: (request) => requests.push(request),
});
await client.putFile(
"https://s3.example/obj?X-Amz-Signature=abc",
new Uint8Array([1, 2]),
);
expect(requests).toEqual([
{ method: "PUT", url: "https://s3.example/obj", status: 200 },
]);
});
});
+257 -11
View File
@@ -9,7 +9,7 @@
* data this file encrypts with libsodium, as Ente's clients encrypt it. So * data this file encrypts with libsodium, as Ente's clients encrypt it. So
* quak's own code fetches each original, decrypts it, checks it against the * quak's own code fetches each original, decrypts it, checks it against the
* hash its uploader recorded and unpacks a live photo, and quak's own retries * hash its uploader recorded and unpacks a live photo, and quak's own retries
* repeat a failed request. Two tests run `lib.backup()` instead, as a program * repeat a failed request. Three tests run `lib.backup()` instead, as a program
* using quak as a library does. * using quak as a library does.
* *
* The account, user 42, has three albums: * The account, user 42, has three albums:
@@ -54,6 +54,7 @@ import {
saveSession, saveSession,
type CliContext, type CliContext,
} from "../../src/cli-commands.js"; } from "../../src/cli-commands.js";
import { stderrLog } from "../../src/cli-log.js";
import { run } from "../../src/cli-run.js"; import { run } from "../../src/cli-run.js";
import { loadSession } from "../../src/cli-session.js"; import { loadSession } from "../../src/cli-session.js";
import { Client } from "../../src/client.js"; import { Client } from "../../src/client.js";
@@ -274,15 +275,17 @@ afterAll(() => {
// Run `quak backup <dir>` as `bin/quak.ts` runs it, sending its requests // Run `quak backup <dir>` as `bin/quak.ts` runs it, sending its requests
// through `fetch`, and return its exit code and everything it printed on // through `fetch`, and return its exit code and everything it printed on
// stderr. Its requests are retried as those of `quak backup` are, except that // stderr and on stdout. Its requests are retried as those of `quak backup`
// a retry does not wait. Its cache is beside `dir`, so each run of one // are, except that a retry does not wait. A download is given up after
// directory finds the cache the run before it left, as cron's runs find the // `downloadTimeoutMs` with no bytes received, 60 seconds unless given. Its
// user's. // cache is beside `dir`, so each run of one directory finds the cache the run
// before it left, as cron's runs find the user's.
const backup = async ( const backup = async (
dir: string, dir: string,
opts: { verify?: boolean } = {}, opts: { verify?: boolean; json?: boolean } = {},
fetch = server, fetch = server,
): Promise<{ code: number; stderr: string }> => { downloadTimeoutMs?: number,
): Promise<{ code: number; stderr: string; stdout: string }> => {
let printed = ""; let printed = "";
const stderr = new Writable({ const stderr = new Writable({
write(chunk: Buffer, _encoding, done) { write(chunk: Buffer, _encoding, done) {
@@ -290,8 +293,10 @@ const backup = async (
done(); done();
}, },
}); });
let output = "";
const stdout = new Writable({ const stdout = new Writable({
write(_chunk, _encoding, done) { write(chunk: Buffer, _encoding, done) {
output += chunk.toString();
done(); done();
}, },
}); });
@@ -299,24 +304,26 @@ const backup = async (
const prompted = async (): Promise<never> => { const prompted = async (): Promise<never> => {
throw new Error("quak backup prompted"); throw new Error("quak backup prompted");
}; };
const log = stderrLog(stderr, "info");
const ctx: CliContext = { const ctx: CliContext = {
stdout, stdout,
stderr, log,
sessionDir, sessionDir,
cacheDir: `${dir}-cache`, cacheDir: `${dir}-cache`,
loadSession: (path) => loadSession: (path) =>
loadSession(path, { loadSession(path, {
fetch, fetch,
retry: { ...UNATTENDED_RETRY_OPTIONS, sleep: async () => {} }, retry: { ...UNATTENDED_RETRY_OPTIONS, sleep: async () => {} },
downloadTimeoutMs,
}), }),
login: prompted, login: prompted,
prompt: prompted, prompt: prompted,
promptSecret: prompted, promptSecret: prompted,
}; };
const code = await new Promise<number>((resolve) => { const code = await new Promise<number>((resolve) => {
void run(backupCommand(ctx, dir, opts), stdout, stderr, resolve); void run(backupCommand(ctx, dir, opts), stdout, stderr, log, resolve);
}); });
return { code, stderr: printed }; return { code, stderr: printed, stdout: output };
}; };
// The ID of the file whose original a request downloads, or undefined for any // The ID of the file whose original a request downloads, or undefined for any
@@ -627,6 +634,40 @@ describe("quak backup", () => {
).toEqual([]); ).toEqual([]);
}); });
// The user has put a file in collections/ with the name of the Trips
// folder, so quak cannot make that folder. It says so on one line, writes
// the two albums after it, and exits 1, and --json lists the album's ID
// in `failedAlbums`. No file failed, so there is no failures.json.
it("exits 1 when an album's folder cannot be made, writes the other albums, and lists it in --json", async () => {
const dir = mkdtempSync(join(root, "backup-"));
mkdirSync(join(dir, "collections"));
writeFileSync(join(dir, "collections", "Trips"), "mine");
const first = await backup(dir);
const { code, stdout } = await backup(dir, { json: true });
expect(first.code).toBe(1);
expect(first.stderr).toMatch(/^FAILED album Trips: EEXIST: /m);
expect(code).toBe(1);
expect(JSON.parse(stdout)).toMatchObject({
failed: 0,
undecryptableAlbums: [],
failedAlbums: [10],
});
const collections = join(dir, "collections");
expect(readJSON(join(collections, "Family.json")).id).toBe(20);
expect(readdirSync(join(collections, "Family")).sort()).toEqual([
"IMG_0002.heic",
"IMG_0002.mov",
"beach.jpg",
]);
expect(readJSON(join(collections, "Friend's Wedding.json")).id).toBe(
30,
);
expect(existsSync(join(collections, "Trips.json"))).toBe(false);
expect(existsSync(join(dir, "failures.json"))).toBe(false);
});
// The server no longer accepts the saved session's token, as after // The server no longer accepts the saved session's token, as after
// `quak logout` elsewhere, and answers every request with 401. The run // `quak logout` elsewhere, and answers every request with 401. The run
// stops at the refresh it starts with, says to log in again, and exits 3. // stops at the refresh it starts with, says to log in again, and exits 3.
@@ -724,6 +765,81 @@ describe("quak backup", () => {
}, },
); );
// The download of toast.mp4 fails as bun's `fetch` reports a failure, in
// the binary `make install` builds: a plain `Error` with bun's own code
// and message, and no cause. These are what bun gave for a refused
// connection, a name that did not resolve and a reset connection. The
// file is tried as often as under Node, and goes into `failures.json`
// with that message, as a transient failure.
it.each([
[
"ConnectionRefused",
"Unable to connect. Is the computer able to access the url?",
],
["FailedToOpenSocket", "Was there a typo in the url or port?"],
[
"ECONNRESET",
"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()",
],
])(
"records a download that failed with bun's %s in failures.json, as transient",
async (bunCode, message) => {
let attempts = 0;
const fetch: typeof globalThis.fetch = async (input, init) => {
if (downloadOf(input) === 200) {
attempts++;
throw Object.assign(new Error(message), {
code: bunCode,
errno: 0,
});
}
return server(input, init);
};
const dir = mkdtempSync(join(root, "backup-"));
const { code } = await backup(dir, {}, fetch);
expect(code).toBe(1);
expect(attempts).toBe(UNATTENDED_RETRY_OPTIONS.attempts);
const failures = readJSON(join(dir, "failures.json"));
expect(failures.files["200"]).toMatchObject({
classification: "transient",
error: message,
});
},
);
// The download of toast.mp4 stalls: the server sends the headers and then
// never a byte of the body. Each attempt is given up after 50 ms, quak's
// own deadline for this run, with a `TimeoutError` whose message says
// only that the download stalled. The file goes into `failures.json` as a
// transient failure, as a request that ran out of time is retried.
it("records a stalled download in failures.json, as transient", async () => {
let attempts = 0;
const fetch: typeof globalThis.fetch = async (input, init) => {
if (downloadOf(input) === 200) {
attempts++;
return new Response(
new ReadableStream<Uint8Array>({
pull: () => new Promise<void>(() => {}),
}),
);
}
return server(input, init);
};
const dir = mkdtempSync(join(root, "backup-"));
const { code } = await backup(dir, {}, fetch, 50);
expect(code).toBe(1);
expect(attempts).toBe(UNATTENDED_RETRY_OPTIONS.attempts);
const failures = readJSON(join(dir, "failures.json"));
expect(failures.files["200"]).toMatchObject({
classification: "transient",
error: "download stalled: no bytes received for 50 ms",
});
});
// The name of Ente's API server does not resolve when quak fetches the ML // The name of Ente's API server does not resolve when quak fetches the ML
// data, after every original has been backed up. No file has ML data // data, after every original has been backed up. No file has ML data
// cached yet, so each goes into `failures.json` with the reason, as a // cached yet, so each goes into `failures.json` with the reason, as a
@@ -763,6 +879,133 @@ describe("quak backup", () => {
]); ]);
}); });
// The server sends beach.jpg's ML data encrypted under a key that is not
// the file's, so quak cannot decrypt it. The file's JSON has the reason
// in place of the ML data, and the file goes into `failures.json` with
// it, so the exit code is 1. The other two files have no ML data, which
// is no failure.
it("records a file whose ML data does not decrypt in its JSON and in failures.json, and exits 1", async () => {
const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const mlData = encryptStreamBlob(
gzipSync(JSON.stringify(ML_DATA)),
otherKey,
);
const fetch: typeof globalThis.fetch = async (input, init) => {
if (new URL(String(input)).pathname !== "/files/data/fetch") {
return server(input, init);
}
const data = [
{
fileID: BEACH.id,
encryptedData: toBase64(mlData.ciphertext),
decryptionHeader: toBase64(mlData.header),
},
];
return new Response(JSON.stringify({ data }), {
headers: { "content-type": "application/json" },
});
};
const dir = mkdtempSync(join(root, "backup-"));
const { code, stderr } = await backup(dir, {}, fetch);
expect(code).toBe(1);
const reason = "secretstream chunk authentication failed";
const beachJSON = readJSON(join(dir, DAY, "2024-03-01.100.json"));
expect(beachJSON).not.toHaveProperty("mlData");
expect(beachJSON.mlDataError).toBe(reason);
const failures = readJSON(join(dir, "failures.json"));
expect(Object.keys(failures.files)).toEqual(["100"]);
expect(failures.files["100"]).toMatchObject({
fileID: 100,
title: "beach.jpg",
error: `ML data: ${reason}`,
});
expect(stderr).toContain(`beach.jpg (id 100): ML data: ${reason}\n`);
for (const fileID of [200, 300]) {
const json = readJSON(join(dir, DAY, `2024-03-01.${fileID}.json`));
expect(json).not.toHaveProperty("mlData");
expect(json).not.toHaveProperty("mlDataError");
}
});
// A program using quak as a library backs the account up, and another ML
// data fetch, as one the library's refresh starts, fails while the backup
// writes the JSON files. The backup's own fetch got beach.jpg's ML data
// under a key that is not the file's; the fetch that fails leaves that
// reason in place, so beach.jpg's JSON and `failures.json` still give it.
it("keeps the reason in beach.jpg's JSON and in failures.json when an ML data fetch fails while lib.backup() writes the JSON files", async () => {
const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const mlData = encryptStreamBlob(
gzipSync(JSON.stringify(ML_DATA)),
otherKey,
);
let mlDataFetchFails = false;
const fetch: typeof globalThis.fetch = async (input, init) => {
if (new URL(String(input)).pathname !== "/files/data/fetch") {
return server(input, init);
}
if (mlDataFetchFails) throw new Error("the connection was reset");
const data = [
{
fileID: BEACH.id,
encryptedData: toBase64(mlData.ciphertext),
decryptionHeader: toBase64(mlData.header),
},
];
return new Response(JSON.stringify({ data }), {
headers: { "content-type": "application/json" },
});
};
const dir = mkdtempSync(join(root, "backup-"));
const lib = await Library.open({
client: loadSession(join(sessionDir, "session.json"), {
fetch,
retry: { ...UNATTENDED_RETRY_OPTIONS, sleep: async () => {} },
})!,
cacheDirectory: `${dir}-cache`,
downloadDirectory: dir,
refreshIntervalSeconds: Infinity,
precacheThumbnails: false,
precacheOriginals: false,
});
try {
// The backup reads a file's cached ML data just before it writes
// that file's JSON. The first time, a refresh starts an ML data
// fetch whose request fails, and the backup goes on once that
// fetch has failed.
const forFile = lib.mldata.forFile.bind(lib.mldata);
vi.spyOn(lib.mldata, "forFile").mockImplementationOnce(
async (args) => {
mlDataFetchFails = true;
await lib.fresh();
await vi.waitFor(
() => expect(lib.status().lastMLError).toBeDefined(),
{ timeout: 2000, interval: 5 },
);
return forFile(args);
},
);
const result = await lib.backup();
expect(result.failed).toBe(1);
const reason = "secretstream chunk authentication failed";
const beachJSON = readJSON(join(dir, DAY, "2024-03-01.100.json"));
expect(beachJSON).not.toHaveProperty("mlData");
expect(beachJSON.mlDataError).toBe(reason);
const failures = readJSON(join(dir, "failures.json"));
expect(Object.keys(failures.files)).toEqual(["100"]);
expect(failures.files["100"]).toMatchObject({
fileID: 100,
title: "beach.jpg",
error: `ML data: ${reason}`,
});
} finally {
await lib.close();
}
});
// Something changed beach.jpg on disk after it was backed up. A run with // Something changed beach.jpg on disk after it was backed up. A run with
// --verify hashes every stored original, finds that one no longer matches // --verify hashes every stored original, finds that one no longer matches
// the hash Ente recorded, says so, and downloads it again; it put back the // the hash Ente recorded, says so, and downloads it again; it put back the
@@ -863,6 +1106,9 @@ describe("a backup whose lock is lost", () => {
expect(code).toBe(1); expect(code).toBe(1);
expect(stderr).toBe( expect(stderr).toBe(
"Starting backup...\n" + "Starting backup...\n" +
"Fetched the files of 1 of 3 collections\n" +
"Fetched the files of 2 of 3 collections\n" +
"Fetched the files of 3 of 3 collections\n" +
"Refreshing library...\n" + "Refreshing library...\n" +
"Fetching original beach.jpg (100)...\n" + "Fetching original beach.jpg (100)...\n" +
"Fetching original IMG_0002.HEIC (300)...\n" + "Fetching original IMG_0002.HEIC (300)...\n" +
+21 -2
View File
@@ -42,6 +42,7 @@ import {
saveSession, saveSession,
type CliContext, type CliContext,
} from "../../src/cli-commands.js"; } from "../../src/cli-commands.js";
import { stderrLog } from "../../src/cli-log.js";
import { loadSession } from "../../src/cli-session.js"; import { loadSession } from "../../src/cli-session.js";
import { Client, type ClientSnapshot } from "../../src/client.js"; import { Client, type ClientSnapshot } from "../../src/client.js";
import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js"; import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js";
@@ -304,7 +305,7 @@ const context = (ente: FakeEnte): CliContext => {
saveSession(join(root, "session"), ente.snapshot()); saveSession(join(root, "session"), ente.snapshot());
return { return {
stdout, stdout,
stderr, log: stderrLog(stderr, "info"),
sessionDir: join(root, "session"), sessionDir: join(root, "session"),
cacheDir: join(root, "cache"), cacheDir: join(root, "cache"),
loadSession: (path) => loadSession: (path) =>
@@ -370,6 +371,24 @@ describe("quak backup with records it cannot decrypt", () => {
expect(stderr.text).toContain(" Failed: 2\n"); expect(stderr.text).toContain(" Failed: 2\n");
}); });
// The album is an error line. The last line is fatal and counts the files
// and the album, so `-q`, which prints fatal lines only, still says why
// the backup exits 1.
it("logs the album at error, and ends with one fatal line counting the files and the album", async () => {
const ctx = {
...context(brokenAccount()),
log: stderrLog(stderr, "error"),
};
expect(await backupCommand(ctx, backupDir(), {})).toBe(1);
expect(stderr.text).toMatch(
/^FAILED album 2: could not be decrypted: /,
);
expect(stderr.text.split("\n").at(-2)).toBe(
`quak: 2 file(s) and 1 album(s) failed; see ${join(backupDir(), "failures.json")} and the FAILED lines`,
);
});
it("names each album it cannot decrypt in the --json result", async () => { it("names each album it cannot decrypt in the --json result", async () => {
expect(await backup(brokenAccount(), { json: true })).toBe(1); expect(await backup(brokenAccount(), { json: true })).toBe(1);
@@ -548,7 +567,7 @@ describe("the read commands with records quak cannot decrypt", () => {
expect(await collectionsCommand(context(ente), {})).toBe(0); expect(await collectionsCommand(context(ente), {})).toBe(0);
expect(stdout.text).toBe("1\talbum\tTrip\n"); expect(stdout.text).toBe("1\talbum\tTrip\n");
expect(await filesCommand(context(ente), { collection: "1" })).toBe(0); expect(await filesCommand(context(ente), "1", {})).toBe(0);
expect(stdout.text).toBe("10\timage\tphoto-10.jpg\n"); expect(stdout.text).toBe("10\timage\tphoto-10.jpg\n");
}); });
}); });
+339 -27
View File
@@ -191,9 +191,12 @@ 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, to exercise per-file resilience. // told to fail one fileID's original, as a server error or as a 404, or to
// 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;
} }
@@ -204,8 +207,10 @@ 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;
writeFileSync(destination, Buffer.alloc(size)); const fill = s.otherBytesID === f.id ? 2 : 0;
writeFileSync(destination, Buffer.alloc(size, fill));
return { bytesWritten: size }; return { bytesWritten: size };
}, },
thumbnail: async ({ file: f, destination }) => { thumbnail: async ({ file: f, destination }) => {
@@ -696,17 +701,26 @@ 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`;
writeFileSync(join(day, leftover), "partial"); for (const name of [leftover, ...replacement, inProgress]) {
writeFileSync(join(day, inProgress), "partial"); writeFileSync(join(day, name), "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();
}); });
@@ -938,13 +952,14 @@ 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: { async fetchMLData(args: { fileIDs: number[] }): Promise<{
fileIDs: number[]; payloads: Map<number, MLData>;
}): Promise<Map<number, MLData>> { errors: Map<number, unknown>;
}> {
if (this.mlError) throw new Error(this.mlError); if (this.mlError) throw new Error(this.mlError);
const result = new Map<number, MLData>(); const payloads = new Map<number, MLData>();
if (args.fileIDs.includes(100)) result.set(100, ML_PAYLOAD); if (args.fileIDs.includes(100)) payloads.set(100, ML_PAYLOAD);
return result; return { payloads, errors: new Map() };
} }
} }
@@ -1294,7 +1309,8 @@ 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. // match the content hash its metadata records is downloaded again, and replaced
// 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.
@@ -1337,6 +1353,12 @@ 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;
@@ -1381,13 +1403,16 @@ 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();
}); });
it("records a failed download in failures.json, with the original removed", async () => { // A copy whose hash does not match is still mostly the photo, so the
// 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.failID = 100; source.notFoundID = 100;
const result = await lib.backup({ const result = await lib.backup({
downloadDirectory: outDir, downloadDirectory: outDir,
@@ -1400,18 +1425,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(existsSync(saved(outDir, "100.jpg"))).toBe(false); expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(corrupt);
expect(
readlinkSync(join(outDir, "collections", "Vacation", "beach.jpg")),
).toBe(linkTo("100.jpg"));
expect(tempFiles(outDir)).toEqual([]);
await lib.close(); await lib.close();
}); });
it("records in failures.json an original the cache puts back with the same bad bytes", async () => { it("keeps an original that does not match its hash when its download does not match either", async () => {
const lib = await openLibrary(stubSource(), new HashedClient()); const { lib, source, outDir } = await backedUp();
const outDir = join(root, "backup"); writeFileSync(saved(outDir, "100.jpg"), corrupt);
// The cache holds a bad copy, and a backup copies an original the source.otherBytesID = 100;
// 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,
@@ -1424,10 +1449,39 @@ 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 original put back does not match its content hash either", "the downloaded original 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();
}); });
@@ -1498,23 +1552,29 @@ describe("backup album folders", () => {
files: EnteFile[]; files: EnteFile[];
} }
const libraryOf = (albums: Album[]): BackupLibrary => ({ // The original of the file `notFound`, if given, always fails to download.
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: () => [], 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, 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 }),
}); });
@@ -1600,6 +1660,251 @@ 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 and the "a"
// before them, so each is cut short to 250 bytes, which leaves one, for
// the "a". File 1's extension is 303 bytes and file 2's 271. 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 = `a (1).${"L".repeat(249)}`;
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 and the "x" before it. 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/x (1).${"Q".repeat(245)} (2) -> ${linkTo("1.bin")}`,
`Trip/x (2).${"Q".repeat(249)} -> ${linkTo("2.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([
@@ -1996,11 +2301,12 @@ 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("removes both files of a live photo that does not match its hash when downloading it again fails", async () => { it("keeps 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"),
); );
@@ -2018,8 +2324,14 @@ 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(existsSync(saved(outDir, "500.heic"))).toBe(false); expect(readFileSync(saved(outDir, "500.heic"))).toEqual(
expect(existsSync(saved(outDir, "500.mov"))).toBe(false); Buffer.from(IMAGE),
);
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();
}); });
+34 -8
View File
@@ -1,6 +1,8 @@
/** /**
* Tests for the retry options `bin/quak.ts` loads the saved session with: * Tests for the retry options `bin/quak.ts` loads the saved session with
* `quak backup` gets the unattended ones, every other command the default. * (`quak backup` gets the unattended ones, every other command the default),
* each with the callbacks that log its requests and retries, and for
* `quak files` taking its collection ID as an optional argument.
* *
* Each test runs `bin/quak.ts`, as the smoke test does, with its session loader * Each test runs `bin/quak.ts`, as the smoke test does, with its session loader
* replaced by one that records the options it is given and reports no session, * replaced by one that records the options it is given and reports no session,
@@ -26,14 +28,19 @@ vi.mock("../../src/cli-session.js", () => ({
const argv = process.argv; const argv = process.argv;
const dir = mkdtempSync(join(tmpdir(), "quak-bin-test-")); const dir = mkdtempSync(join(tmpdir(), "quak-bin-test-"));
// Run `quak <args>` to completion. The "Not logged in" message and the exit // Run `quak <args>` to completion and return what it wrote on stderr, which is
// are swallowed. // kept off the terminal. The exit is swallowed.
const quak = async (...args: string[]): Promise<void> => { const quak = async (...args: string[]): Promise<string> => {
vi.spyOn(process.stderr, "write").mockImplementation(() => true); let written = "";
vi.spyOn(process.stderr, "write").mockImplementation((text) => {
written += String(text);
return true;
});
vi.spyOn(process, "exit").mockImplementation(() => undefined as never); vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
process.argv = ["node", "quak", ...args]; process.argv = ["node", "quak", ...args];
vi.resetModules(); vi.resetModules();
await import("../../bin/quak.js"); await import("../../bin/quak.js");
return written;
}; };
afterEach(() => { afterEach(() => {
@@ -59,13 +66,32 @@ describe("bin/quak.ts session loading", () => {
attempts, attempts,
baseDelayMs, baseDelayMs,
maxDelayMs, maxDelayMs,
onRetry: expect.any(Function),
}), }),
onRequest: expect.any(Function),
}, },
]); ]);
}); });
it("loads the session for another command with the default options", async () => { it("loads the session for another command with the default retry options", async () => {
await quak("collections"); await quak("collections");
expect(loaded).toEqual([undefined]); expect(loaded).toEqual([
{
retry: { onRetry: expect.any(Function) },
onRequest: expect.any(Function),
},
]);
});
});
// The command line takes `files` with or without a collection ID, and the
// command runs as far as its session check either way (issue #211).
describe("bin/quak.ts files", () => {
it("runs without a collection ID", async () => {
expect(await quak("files")).toMatch(/^Not logged in/);
});
it("runs with a collection ID", async () => {
expect(await quak("files", "7")).toMatch(/^Not logged in/);
}); });
}); });
+498 -49
View File
@@ -21,7 +21,7 @@ import {
utimesSync, utimesSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { join } from "node:path"; import { basename, join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { PassThrough } from "node:stream"; import { PassThrough } from "node:stream";
import * as jpegJs from "jpeg-js"; import * as jpegJs from "jpeg-js";
@@ -51,13 +51,24 @@ import {
listMissingThumbnailsCommand, listMissingThumbnailsCommand,
fixMissingThumbnailsCommand, fixMissingThumbnailsCommand,
} from "../../src/cli-commands.js"; } from "../../src/cli-commands.js";
import { stderrLog } from "../../src/cli-log.js";
import { run } from "../../src/cli-run.js"; import { run } from "../../src/cli-run.js";
import { loadSession } from "../../src/cli-session.js"; import { loadSession } from "../../src/cli-session.js";
import type { Client, ClientSnapshot, LoginOptions } from "../../src/client.js"; import {
Client,
type ClientSnapshot,
type LoginOptions,
} from "../../src/client.js";
import { savePath, type ContentSource } from "../../src/library/content.js"; import { savePath, type ContentSource } from "../../src/library/content.js";
import type { Collection, EnteFile } from "../../src/model/types.js"; import type { Collection, EnteFile } from "../../src/model/types.js";
import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js"; import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js";
import { defaultCacheDirectory } from "../../src/library/index.js"; import { defaultCacheDirectory } from "../../src/library/index.js";
import {
buildAccount,
buildMetaFetch,
encryptSecretbox,
encryptStreamBlob,
} from "../ente-server.js";
import { HEIC_WITH_EXIF } from "../exif-heic.js"; import { HEIC_WITH_EXIF } from "../exif-heic.js";
import { import {
asLivePhoto, asLivePhoto,
@@ -111,25 +122,31 @@ const FILES: Record<number, EnteFile[]> = {
// An original is 7 bytes and a thumbnail 3. `failID` makes that file's // An original is 7 bytes and a thumbnail 3. `failID` makes that file's
// original fail; `emptyThumbID` makes the server report that file's // original fail; `emptyThumbID` makes the server report that file's
// thumbnail as empty. `withNewFile` adds new.jpg (102) to Vacation, advancing // thumbnail as empty. `withNewFile` adds new.jpg (102) to Vacation, advancing
// the collection's updationTime as the server does, and `refreshError` makes // the collection's updationTime as the server does. `beachInWork` adds
// listing collections fail with that message. // beach.jpg (100) to Work as well, so that one file is in both albums.
// `refreshError` makes listing collections fail with that message.
const fakeClient = ( const fakeClient = (
opts: { opts: {
failID?: number; failID?: number;
emptyThumbID?: number; emptyThumbID?: number;
withNewFile?: boolean; withNewFile?: boolean;
beachInWork?: boolean;
refreshError?: string; refreshError?: string;
} = {}, } = {},
) => { ) => {
const collections = opts.withNewFile const collections = opts.withNewFile
? [{ ...COLLECTIONS[0], updationTime: 2 }, COLLECTIONS[1]] ? [{ ...COLLECTIONS[0], updationTime: 2 }, COLLECTIONS[1]]
: COLLECTIONS; : COLLECTIONS;
const files = opts.withNewFile let files = FILES;
? { if (opts.withNewFile) {
...FILES, files = {
1: [...FILES[1], { ...file(102, 1, "new.jpg"), updationTime: 2 }], ...files,
} 1: [...files[1], { ...file(102, 1, "new.jpg"), updationTime: 2 }],
: FILES; };
}
if (opts.beachInWork) {
files = { ...files, 2: [...files[2], file(100, 2, "beach.jpg")] };
}
const source: ContentSource = { const source: ContentSource = {
original: async ({ file: f, destination }) => { original: async ({ file: f, destination }) => {
if (f.id === opts.failID) throw new Error("HTTP 500 from server"); if (f.id === opts.failID) throw new Error("HTTP 500 from server");
@@ -171,6 +188,15 @@ const fakeClient = (
return fake as unknown as Client; return fake as unknown as Client;
}; };
// What a read command prints on stderr while the refresh it waits for fetches
// the files of each of the fake's two albums, on an empty cache.
const FETCHED_BOTH_ALBUMS =
"Fetched the files of 1 of 2 collections\n" +
"Fetched the files of 2 of 2 collections\n";
// The same for an account with one album.
const FETCHED_ONE_ALBUM = "Fetched the files of 1 of 1 collections\n";
// Collects everything written to it. // Collects everything written to it.
class Output { class Output {
text = ""; text = "";
@@ -183,9 +209,11 @@ let root: string;
let stdout: Output; let stdout: Output;
let stderr: Output; let stderr: Output;
// The commands log at the default level, info, as `quak` without `-v` or `-q`
// does; the levels have their own tests, in `log.test.ts`.
const context = (client: Client | null = fakeClient()): CliContext => ({ const context = (client: Client | null = fakeClient()): CliContext => ({
stdout, stdout,
stderr, log: stderrLog(stderr, "info"),
sessionDir: join(root, "session"), sessionDir: join(root, "session"),
cacheDir: join(root, "cache"), cacheDir: join(root, "cache"),
loadSession: () => client, loadSession: () => client,
@@ -238,7 +266,7 @@ describe("session file", () => {
const dir = join(root, "backup"); const dir = join(root, "backup");
expect(await whoamiCommand(ctx)).toBe(3); expect(await whoamiCommand(ctx)).toBe(3);
expect(await collectionsCommand(ctx, {})).toBe(3); expect(await collectionsCommand(ctx, {})).toBe(3);
expect(await filesCommand(ctx, { collection: "1" })).toBe(3); expect(await filesCommand(ctx, "1", {})).toBe(3);
expect(await getCommand(ctx, "100", {})).toBe(3); expect(await getCommand(ctx, "100", {})).toBe(3);
expect(await getThumbCommand(ctx, "100", {})).toBe(3); expect(await getThumbCommand(ctx, "100", {})).toBe(3);
expect(await backupMetadataCommand(ctx, dir, {})).toBe(3); expect(await backupMetadataCommand(ctx, dir, {})).toBe(3);
@@ -485,17 +513,14 @@ describe("collections", () => {
describe("files", () => { describe("files", () => {
it("prints one tab-separated line per file", async () => { it("prints one tab-separated line per file", async () => {
expect(await filesCommand(context(), { collection: "1" })).toBe(0); expect(await filesCommand(context(), "1", {})).toBe(0);
expect(stdout.text).toBe( expect(stdout.text).toBe(
"100\timage\tbeach.jpg\n" + "101\timage\tsunset.jpg\n", "100\timage\tbeach.jpg\n" + "101\timage\tsunset.jpg\n",
); );
}); });
it("prints a JSON array with --json", async () => { it("prints a JSON array with --json", async () => {
const code = await filesCommand(context(), { const code = await filesCommand(context(), "2", { json: true });
collection: "2",
json: true,
});
expect(code).toBe(0); expect(code).toBe(0);
expect(JSON.parse(stdout.text)).toEqual([ expect(JSON.parse(stdout.text)).toEqual([
{ {
@@ -509,8 +534,42 @@ describe("files", () => {
}); });
it("exits 1 for an unknown collection", async () => { it("exits 1 for an unknown collection", async () => {
expect(await filesCommand(context(), { collection: "9" })).toBe(1); expect(await filesCommand(context(), "9", {})).toBe(1);
expect(stderr.text).toBe("Collection 9 not found\n"); expect(stderr.text).toBe(
FETCHED_BOTH_ALBUMS + "Collection 9 not found\n",
);
});
// Without a collection ID it lists every file in the account once (issue
// #211). beach.jpg is in Vacation and Work, and Vacation comes first, so it
// is listed once, as Vacation's.
it("without a collection ID prints every file once", async () => {
const ctx = context(fakeClient({ beachInWork: true }));
expect(await filesCommand(ctx, undefined, {})).toBe(0);
expect(stdout.text).toBe(
"100\timage\tbeach.jpg\n" +
"101\timage\tsunset.jpg\n" +
"200\timage\tdiagram.png\n",
);
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS);
});
it("without a collection ID gives each file the first album holding it with --json", async () => {
const ctx = context(fakeClient({ beachInWork: true }));
expect(await filesCommand(ctx, undefined, { json: true })).toBe(0);
expect(
JSON.parse(stdout.text).map(
(row: { id: number; collectionID: number }) => [
row.id,
row.collectionID,
],
),
).toEqual([
[100, 1],
[101, 1],
[200, 2],
]);
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS);
}); });
}); });
@@ -519,39 +578,134 @@ describe("get and get-thumb", () => {
const out = join(root, "diagram.png"); const out = join(root, "diagram.png");
expect(await getCommand(context(), "200", { out })).toBe(0); expect(await getCommand(context(), "200", { out })).toBe(0);
expect(readFileSync(out)).toEqual(Buffer.alloc(7, 200)); expect(readFileSync(out)).toEqual(Buffer.alloc(7, 200));
expect(stderr.text).toBe(`7 bytes -> ${out}\n`); expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + `7 bytes -> ${out}\n`);
}); });
it("get-thumb finds a file in any album without --collection", async () => { it("get-thumb finds a file in any album without --collection", async () => {
const out = join(root, "thumb.jpg"); const out = join(root, "thumb.jpg");
expect(await getThumbCommand(context(), "200", { out })).toBe(0); expect(await getThumbCommand(context(), "200", { out })).toBe(0);
expect(readFileSync(out)).toEqual(Buffer.alloc(3, 200)); expect(readFileSync(out)).toEqual(Buffer.alloc(3, 200));
expect(stderr.text).toBe(`3 bytes -> ${out}\n`); expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + `3 bytes -> ${out}\n`);
}); });
it("get exits 1 when no album has the file", async () => { it("get exits 1 when no album has the file", async () => {
const out = join(root, "x"); const out = join(root, "x");
expect(await getCommand(context(), "999", { out })).toBe(1); expect(await getCommand(context(), "999", { out })).toBe(1);
expect(stderr.text).toBe("File 999 not found\n"); expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + "File 999 not found\n");
expect(existsSync(out)).toBe(false); expect(existsSync(out)).toBe(false);
}); });
it("get-thumb exits 1 when no album has the file", async () => { it("get-thumb exits 1 when no album has the file", async () => {
const out = join(root, "x"); const out = join(root, "x");
expect(await getThumbCommand(context(), "999", { out })).toBe(1); expect(await getThumbCommand(context(), "999", { out })).toBe(1);
expect(stderr.text).toBe("File 999 not found\n"); expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + "File 999 not found\n");
expect(existsSync(out)).toBe(false); expect(existsSync(out)).toBe(false);
}); });
}); });
// A file system refuses a file name over 255 bytes. The account here is one
// album, whose name is 300 bytes, holding one file, 700, with the title given.
describe("names from an album name or a title over 255 bytes", () => {
const longNames = (title: string): Client =>
({
...fakeClient(),
collectionsSince: async () => ({
collections: [collection(7, "A".repeat(300))],
deleted: [],
cursor: 1,
}),
filesSince: async () => ({
files: [file(700, 7, title)],
deleted: [],
cursor: 1,
}),
}) as unknown as Client;
const longTitle = `${"t".repeat(296)}.jpg`;
// Run `command` in a new directory, where `get` and `get-thumb` without
// `--out` write, and return its exit code and the directory.
const inNewDirectory = async (
command: () => Promise<number>,
): Promise<{ code: number; dir: string }> => {
const dir = mkdtempSync(join(root, "cwd-"));
const previous = process.cwd();
process.chdir(dir);
try {
return { code: await command(), dir };
} finally {
process.chdir(previous);
}
};
it("get cuts the title short to 255 bytes, keeping its extension, and exits 0", async () => {
const { code, dir } = await inNewDirectory(() =>
getCommand(context(longNames(longTitle)), "700", {}),
);
const name = `${"t".repeat(251)}.jpg`;
expect(Buffer.byteLength(name)).toBe(255);
expect(code).toBe(0);
expect(readdirSync(dir)).toEqual([name]);
expect(stderr.text).toBe(FETCHED_ONE_ALBUM + `7 bytes -> ${name}\n`);
});
it("get-thumb cuts the title short so that thumb_ and it fit in 255 bytes, and exits 0", async () => {
const { code, dir } = await inNewDirectory(() =>
getThumbCommand(context(longNames(longTitle)), "700", {}),
);
const name = `thumb_${"t".repeat(245)}.jpg`;
expect(Buffer.byteLength(name)).toBe(255);
expect(code).toBe(0);
expect(readdirSync(dir)).toEqual([name]);
expect(stderr.text).toBe(FETCHED_ONE_ALBUM + `3 bytes -> ${name}\n`);
});
it("backup-metadata cuts the album name short so that its folder's name fits in 255 bytes, and exits 0", async () => {
const dir = join(root, "dump");
const code = await backupMetadataCommand(
context(longNames("beach.jpg")),
dir,
{},
);
const folder = `7-${"A".repeat(253)}`;
expect(Buffer.byteLength(folder)).toBe(255);
expect(code).toBe(0);
expect(readdirSync(join(dir, "collections"))).toEqual([folder]);
expect(readdirSync(join(dir, "collections", folder)).sort()).toEqual([
"700.json",
"_collection.json",
]);
});
// The title's extension is 300 letters, too long for the save path's
// name, so the original is saved with `.bin`.
it("backup saves a title with a 300-letter extension as .bin, and exits 0", async () => {
const dir = join(root, "backup");
const title = `b.${"e".repeat(300)}`;
const code = await backupCommand(context(longNames(title)), dir, {});
const path = savePath(dir, file(700, 7, title));
expect(code).toBe(0);
expect(basename(path)).toMatch(/^\d{4}-\d{2}-\d{2}\.700\.bin$/);
expect(readFileSync(path)).toEqual(Buffer.alloc(7, 700 & 0xff));
});
});
// 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
// video, which a photo viewer can open. The account here is Vacation holding // video, which a photo viewer can open. The account here is Vacation holding
// one live photo, 300, downloaded through the real download layer // one live photo, 300, downloaded through the real download layer
// (test/live-photo.ts). // (test/live-photo.ts).
describe("a live photo", () => { describe("a live photo", () => {
const livePhotoClient = async (image = IMAGE): Promise<Client> => { const livePhotoClient = async (
image = IMAGE,
title = "IMG_0300.HEIC",
): Promise<Client> => {
const { file: live, body } = await asLivePhoto( const { file: live, body } = await asLivePhoto(
file(300, 1, "IMG_0300.HEIC"), file(300, 1, title),
livePhotoZip({ "image.heic": image, "video.mov": VIDEO }), livePhotoZip({ "image.heic": image, "video.mov": VIDEO }),
livePhotoHash(image, VIDEO), livePhotoHash(image, VIDEO),
); );
@@ -593,11 +747,50 @@ describe("a live photo", () => {
Buffer.from(VIDEO), Buffer.from(VIDEO),
); );
expect(stderr.text).toBe( expect(stderr.text).toBe(
`${IMAGE.length} bytes -> IMG_0300.heic\n` + FETCHED_ONE_ALBUM +
`${IMAGE.length} bytes -> IMG_0300.heic\n` +
`${VIDEO.length} bytes -> IMG_0300.mov\n`, `${VIDEO.length} bytes -> IMG_0300.mov\n`,
); );
}); });
// Run `get` without `--out` in a new directory for a live photo titled
// `title`, and return its exit code and the names it wrote.
const getInNewDirectory = async (
title: string,
): Promise<{ code: number; names: string[] }> => {
const ctx = context(await livePhotoClient(IMAGE, title));
const dir = mkdtempSync(join(root, "cwd-"));
const previous = process.cwd();
process.chdir(dir);
try {
const code = await getCommand(ctx, "300", {});
return { code, names: readdirSync(dir).sort() };
} finally {
process.chdir(previous);
}
};
const imageName = `${"t".repeat(250)}.heic`;
const videoName = `${"t".repeat(251)}.mov`;
// The title fits, but ".heic" is one byte longer than ".jpg", so the
// image's name is cut short once its extension is in it.
it("get cuts its image's name short for a 255-byte title ending in .jpg, and exits 0", async () => {
const title = `${"t".repeat(251)}.jpg`;
const result = await getInNewDirectory(title);
expect(Buffer.byteLength(title)).toBe(255);
expect(Buffer.byteLength(imageName)).toBe(255);
expect(Buffer.byteLength(videoName)).toBe(255);
expect(result).toEqual({ code: 0, names: [imageName, videoName] });
});
it("get cuts the names of its image and video short for a 300-byte title with no extension, and exits 0", async () => {
const result = await getInNewDirectory("t".repeat(300));
expect(result).toEqual({ code: 0, names: [imageName, videoName] });
});
it("get --out writes the image there and the video beside it", async () => { it("get --out writes the image there and the video beside it", async () => {
const out = join(root, "photo.jpg"); const out = join(root, "photo.jpg");
const video = join(root, "photo.mov"); const video = join(root, "photo.mov");
@@ -609,7 +802,8 @@ describe("a live photo", () => {
expect(readFileSync(out)).toEqual(Buffer.from(IMAGE)); expect(readFileSync(out)).toEqual(Buffer.from(IMAGE));
expect(readFileSync(video)).toEqual(Buffer.from(VIDEO)); expect(readFileSync(video)).toEqual(Buffer.from(VIDEO));
expect(stderr.text).toBe( expect(stderr.text).toBe(
`${IMAGE.length} bytes -> ${out}\n${VIDEO.length} bytes -> ${video}\n`, FETCHED_ONE_ALBUM +
`${IMAGE.length} bytes -> ${out}\n${VIDEO.length} bytes -> ${video}\n`,
); );
}); });
@@ -623,7 +817,8 @@ describe("a live photo", () => {
expect(existsSync(out)).toBe(false); expect(existsSync(out)).toBe(false);
expect(existsSync(join(root, "photo.mov"))).toBe(false); expect(existsSync(join(root, "photo.mov"))).toBe(false);
expect(stderr.text).toBe( expect(stderr.text).toBe(
`File 300 is a live photo, and its video would also be written to ${out}\n`, FETCHED_ONE_ALBUM +
`File 300 is a live photo, and its video would also be written to ${out}\n`,
); );
}); });
@@ -718,6 +913,40 @@ describe("backup", () => {
); );
}); });
// A failed file is an error line both where the backup fails it and in
// the list at the end. The last line is fatal, so `-q`, which prints
// fatal lines only, still says why the backup exits 1.
it("logs a failed file at error, and ends with one fatal line counting the failures", async () => {
const dir = join(root, "backup");
const last = `quak: 1 file(s) and 0 album(s) failed; see ${join(dir, "failures.json")} and the FAILED lines\n`;
const ctx = context(fakeClient({ failID: 101 }));
expect(
await backupCommand(
{ ...ctx, log: stderrLog(stderr, "error") },
dir,
{},
),
).toBe(1);
expect(stderr.text).toBe(
"FAILED original sunset.jpg: HTTP 500 from server\n" +
"\nFailed files:\n" +
" [Vacation] sunset.jpg (id 101): HTTP 500 from server\n" +
last,
);
stderr.text = "";
expect(
await backupCommand(
{ ...ctx, log: stderrLog(stderr, "fatal") },
dir,
{ json: true },
),
).toBe(1);
expect(stderr.text).toBe(last);
});
// `--json` shapes stdout only: the log lines stay on stderr, and the JSON
// takes the place of the summary.
it("prints the result as JSON with --json, still exiting 1 on a failure", async () => { it("prints the result as JSON with --json, still exiting 1 on a failure", async () => {
const ctx = context(fakeClient({ failID: 101 })); const ctx = context(fakeClient({ failID: 101 }));
const code = await backupCommand(ctx, join(root, "backup"), { const code = await backupCommand(ctx, join(root, "backup"), {
@@ -732,7 +961,31 @@ describe("backup", () => {
failed: 1, failed: 1,
}); });
expect(result.errors[0].fileID).toBe(101); expect(result.errors[0].fileID).toBe(101);
expect(stderr.text).toBe("Starting backup...\n"); expect(stderr.text).toContain(
"Fetching original sunset.jpg (101)...\n",
);
expect(stderr.text).not.toContain("Backup complete");
});
// On an empty cache the library's first refresh fetches the files of
// every album before the backup can start, which on a large account takes
// a while; each album it has fetched the files of is counted on stderr,
// with `--json` too.
it("counts on stderr each album whose files its first refresh fetched, with --json too", async () => {
const start =
"Starting backup...\n" +
FETCHED_BOTH_ALBUMS +
"Refreshing library...\n";
for (const json of [false, true]) {
stderr.text = "";
const dir = mkdtempSync(join(root, "backup-"));
const ctx = {
...context(),
cacheDir: mkdtempSync(join(root, "cache-")),
};
expect(await backupCommand(ctx, dir, { json })).toBe(0);
expect(stderr.text.slice(0, start.length)).toBe(start);
}
}); });
it("--verify downloads again an original that does not match its hash, prints the counts, and exits 0", async () => { it("--verify downloads again an original that does not match its hash, prints the counts, and exits 0", async () => {
@@ -822,6 +1075,7 @@ describe("backup", () => {
backupCommand(ctx, dir, {}), backupCommand(ctx, dir, {}),
new PassThrough(), new PassThrough(),
runStderr, runStderr,
stderrLog(runStderr, "info"),
resolve, resolve,
); );
}); });
@@ -1062,7 +1316,9 @@ describe("helper list-missing-thumbnails", () => {
expect(stderr.text).toContain("No missing thumbnails found.\n"); expect(stderr.text).toContain("No missing thumbnails found.\n");
}); });
it("prints a JSON array with --json and no progress", async () => { // `--json` shapes stdout only: the progress stays on stderr, and the JSON
// takes the place of the count and the list.
it("prints a JSON array with --json, and its progress on stderr", async () => {
const ctx = context(fakeClient({ emptyThumbID: 200 })); const ctx = context(fakeClient({ emptyThumbID: 200 }));
expect(await listMissingThumbnailsCommand(ctx, { json: true })).toBe(0); expect(await listMissingThumbnailsCommand(ctx, { json: true })).toBe(0);
expect(JSON.parse(stdout.text)).toEqual([ expect(JSON.parse(stdout.text)).toEqual([
@@ -1073,7 +1329,37 @@ describe("helper list-missing-thumbnails", () => {
reason: "empty thumbnail (0 bytes)", reason: "empty thumbnail (0 bytes)",
}, },
]); ]);
expect(stderr.text).toBe(""); expect(stderr.text).toContain(FETCHED_BOTH_ALBUMS);
expect(stderr.text).toContain("[Work] Checking thumbnails...\n");
expect(stderr.text).not.toContain("with missing thumbnails");
});
});
describe("helper fix-missing-thumbnails", () => {
// File 999 is in no album, so fixing it fails. The list of failed files
// is at error, and the last line is fatal, so `-q`, which prints fatal
// lines only, still says why the command exits 1.
it("lists a failed file at error, and ends with one fatal line counting the failures", async () => {
const last = `quak: 1 file(s) failed; see the "Failed files:" list, or the JSON with --json\n`;
const ctx = { ...context(), log: stderrLog(stderr, "error") };
expect(await fixMissingThumbnailsCommand(ctx, { file: ["999"] })).toBe(
1,
);
expect(stderr.text).toBe(
"\nFailed files:\n" +
" 999\tunknown\tfile not found in any collection\n" +
last,
);
stderr.text = "";
const quiet = { ...context(), log: stderrLog(stderr, "fatal") };
expect(
await fixMissingThumbnailsCommand(quiet, {
file: ["999"],
json: true,
}),
).toBe(1);
expect(stderr.text).toBe(last);
}); });
}); });
@@ -1103,11 +1389,12 @@ describe("backup-metadata --exif", () => {
}); });
}); });
// Each test first runs `collections` so the cache holds the account as it was, // Each test first runs `files` so the cache holds the account as it was, then
// then changes the server under it. // changes the server under it. (`collections` would not do: it reads the
// collection list alone and stores nothing.)
describe("backup-metadata and the thumbnail helpers refresh first", () => { describe("backup-metadata and the thumbnail helpers refresh first", () => {
beforeEach(async () => { beforeEach(async () => {
expect(await collectionsCommand(context(), {})).toBe(0); expect(await filesCommand(context(), "1", {})).toBe(0);
stdout.text = ""; stdout.text = "";
stderr.text = ""; stderr.text = "";
}); });
@@ -1166,10 +1453,10 @@ describe("backup-metadata and the thumbnail helpers refresh first", () => {
}); });
}); });
// `files --collection`, `get`, `get-thumb` and `helper fix-missing-thumbnails // `files`, `get`, `get-thumb` and `helper fix-missing-thumbnails --file` take
// --file` take an ID only when it is written in decimal digits and is a safe // an ID only when it is written in decimal digits and is a safe integer.
// integer. `Number()` alone reads `0x10` as 16, `1e3` as 1000 and an empty // `Number()` alone reads `0x10` as 16, `1e3` as 1000 and an empty string as 0,
// string as 0, so the command would act on a file the user never named. // 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 // Anything else is refused with one line naming it, and exit 1, before any
// request. // request.
// //
@@ -1223,21 +1510,18 @@ describe("an ID given on the command line", () => {
it("files looks up a decimal collection ID", async () => { it("files looks up a decimal collection ID", async () => {
const ctx = emptyAccount(); const ctx = emptyAccount();
expect(await filesCommand(ctx, { collection: "1" })).toBe(1); expect(await filesCommand(ctx, "1", {})).toBe(1);
expect(stderr.text).toBe("Collection 1 not found\n"); expect(stderr.text).toBe("Collection 1 not found\n");
expect(requests).not.toHaveLength(0); expect(requests).not.toHaveLength(0);
}); });
it.each(NOT_IDS)( it.each(NOT_IDS)("files refuses %j before any request", async (arg) => {
"files refuses --collection %j before any request", const ctx = emptyAccount();
async (arg) => { expect(await filesCommand(ctx, arg, {})).toBe(1);
const ctx = emptyAccount(); expect(stderr.text).toBe(`Invalid collection ID: ${arg}\n`);
expect(await filesCommand(ctx, { collection: arg })).toBe(1); expect(stdout.text).toBe("");
expect(stderr.text).toBe(`Invalid collection ID: ${arg}\n`); expect(requests).toHaveLength(0);
expect(stdout.text).toBe(""); });
expect(requests).toHaveLength(0);
},
);
it("get looks up a decimal file ID", async () => { it("get looks up a decimal file ID", async () => {
expect(await getCommand(emptyAccount(), "100", {})).toBe(1); expect(await getCommand(emptyAccount(), "100", {})).toBe(1);
@@ -1292,3 +1576,168 @@ describe("an ID given on the command line", () => {
}, },
); );
}); });
// An account with many albums (issue #210). On an empty cache `collections`
// used to wait for a refresh that fetched and decrypted the files of every
// album, one album after another, before it printed anything, so on such an
// account it seemed to hang. The account here has 40 albums of one photo each,
// encrypted as Ente's clients encrypt them, on the fake Ente server of
// `test/ente-server.ts`, which also records the path of each request quak
// sends. The session is the one logging in to that server saves.
describe("an account with many albums", () => {
const ALBUMS = 40;
const PASSWORD = "many-albums";
const paths: string[] = [];
let server: typeof globalThis.fetch;
let snapshot: ClientSnapshot;
// Album n is named `Album n` and holds one photo, n * 100, titled
// `photo-n.jpg`.
beforeAll(async () => {
await sodium.ready;
const account = await buildAccount(PASSWORD);
const collections: Record<string, unknown>[] = [];
const filesByCollection: Record<number, Record<string, unknown>[]> = {};
for (let id = 1; id <= ALBUMS; id++) {
const albumKey = sodium.crypto_secretbox_keygen();
const albumKeyBox = encryptSecretbox(albumKey, account.masterKey);
const name = encryptSecretbox(
new TextEncoder().encode(`Album ${id}`),
albumKey,
);
collections.push({
id,
owner: { id: USER_ID },
encryptedKey: toBase64(albumKeyBox.ciphertext),
keyDecryptionNonce: toBase64(albumKeyBox.nonce),
encryptedName: toBase64(name.ciphertext),
nameDecryptionNonce: toBase64(name.nonce),
type: "album",
updationTime: 1,
});
const photoKey =
sodium.crypto_secretstream_xchacha20poly1305_keygen();
const photoKeyBox = encryptSecretbox(photoKey, albumKey);
const metadata = encryptStreamBlob(
new TextEncoder().encode(
JSON.stringify({
title: `photo-${id}.jpg`,
fileType: 0,
creationTime: 1000,
modificationTime: 1000,
}),
),
photoKey,
);
filesByCollection[id] = [
{
id: id * 100,
collectionID: id,
ownerID: USER_ID,
encryptedKey: toBase64(photoKeyBox.ciphertext),
keyDecryptionNonce: toBase64(photoKeyBox.nonce),
metadata: {
encryptedData: toBase64(metadata.ciphertext),
decryptionHeader: toBase64(metadata.header),
},
// These tests fetch no original and no thumbnail.
file: { decryptionHeader: "" },
thumbnail: { decryptionHeader: "" },
updationTime: 1,
},
];
}
const ente = buildMetaFetch({
verifier: account.verifier,
srpAttributes: account.srpAttributes,
keyAttributes: account.keyAttributes,
encryptedToken: account.encryptedToken,
collections,
filesByCollection,
encryptedMLData: {},
fileCiphertexts: {},
});
server = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
paths.push(new URL(url).pathname);
return ente(input, init);
}) as typeof globalThis.fetch;
const client = await Client.login({
email: "many@example.com",
password: PASSWORD,
apiOptions: { fetch: server },
});
snapshot = client.toJSON();
});
beforeEach(() => {
paths.length = 0;
});
// A context whose saved session sends its requests to the fake server.
const accountContext = (): CliContext => {
const ctx = context();
saveSession(ctx.sessionDir, snapshot);
return {
...ctx,
loadSession: (path) => loadSession(path, { fetch: server }),
};
};
it("collections lists every album from one request, fetching no album's files", async () => {
const ctx = accountContext();
expect(await collectionsCommand(ctx, {})).toBe(0);
let albums = "";
for (let id = 1; id <= ALBUMS; id++)
albums += `${id}\talbum\tAlbum ${id}\n`;
expect(stdout.text).toBe(albums);
expect(stderr.text).toBe("");
expect(paths).toEqual(["/collections/v2"]);
// It stored nothing that would make a refresh skip an album's files:
// `files` after it still finds the album's photo.
stdout.text = "";
expect(await filesCommand(ctx, "7", {})).toBe(0);
expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
});
it("files counts on stderr each album whose files its refresh fetched", async () => {
expect(await filesCommand(accountContext(), "7", {})).toBe(0);
expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
let progress = "";
for (let n = 1; n <= ALBUMS; n++) {
progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`;
}
expect(stderr.text).toBe(progress);
});
// `--json` shapes stdout only, so the refresh's progress is still counted
// on stderr.
it("files --json counts on stderr each album whose files its refresh fetched", async () => {
expect(await filesCommand(accountContext(), "7", { json: true })).toBe(
0,
);
expect(JSON.parse(stdout.text)).toEqual([
{
id: 700,
title: "photo-7.jpg",
fileType: "image",
creationTime: 1000,
collectionID: 7,
},
]);
let progress = "";
for (let n = 1; n <= ALBUMS; n++) {
progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`;
}
expect(stderr.text).toBe(progress);
});
});
+357
View File
@@ -0,0 +1,357 @@
/**
* Tests for the CLI's log levels, set with `-v`/`--verbose` and `-q`/`--quiet`
* (`src/cli-log.ts`, issue #212).
*
* Every line quak prints on stderr is a log line at one of six levels, from
* the quietest: fatal, error, warning, info, debug, trace. A run prints the
* lines at its own level and at every quieter one. The level is info unless
* asked otherwise: `-v` raises it to debug, `-vv` to trace, and more repeats
* stay at trace. `-q` lowers it to fatal, so a run prints only the line a
* command prints as it fails with a non-zero exit code. stdout holds the
* command's output at every level.
*
* Most tests run `bin/quak.ts`, as the smoke test does, against an account of
* one album holding one photo: the saved session is in a temporary directory,
* and `fetch` is a fake of the Ente server that serves the album and the photo
* encrypted as Ente's clients encrypt them. Each run has a cache directory of
* its own, so its refresh fetches the album's files, as on a first run.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import sodium from "libsodium-wrappers-sumo";
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from "vitest";
import { ApiClient } from "../../src/api/client.js";
import { saveSession } from "../../src/cli-commands.js";
import { logRefresh, logRequests, type Log } from "../../src/cli-log.js";
import { init, toBase64 } from "../../src/crypto/index.js";
import { encryptSecretbox, encryptStreamBlob } from "../ente-server.js";
// `bin/quak.ts` reads the saved session from the data directory env-paths
// gives it, which here is a temporary directory made before the first run.
const paths = vi.hoisted(() => ({ data: "" }));
vi.mock("env-paths", () => ({ default: () => paths }));
const USER_ID = 42;
const root = mkdtempSync(join(tmpdir(), "quak-log-test-"));
const argv = process.argv;
class ExitCalled extends Error {}
// Run `quak <args>` to its exit, with a cache of its own, and return its exit
// code and what it wrote on stdout and on stderr, which are kept off the
// terminal. `process.exit` is stubbed to throw, so nothing runs after it, as
// nothing does after a real exit.
const quak = async (
...args: string[]
): Promise<{ code: number; stdout: string; stderr: string }> => {
let stdout = "";
let stderr = "";
let code = NaN;
vi.spyOn(process.stdout, "write").mockImplementation((text) => {
stdout += String(text);
return true;
});
vi.spyOn(process.stderr, "write").mockImplementation((text) => {
stderr += String(text);
return true;
});
vi.spyOn(process, "exit").mockImplementation((exitCode) => {
code = Number(exitCode ?? 0);
throw new ExitCalled();
});
const cache = mkdtempSync(join(root, "cache-"));
process.argv = ["node", "quak", "--cache-dir", cache, ...args];
vi.resetModules();
await expect(import("../../bin/quak.js")).rejects.toBeInstanceOf(
ExitCalled,
);
return { code, stdout, stderr };
};
// Album 1, "Trips", of user 42, holding photo 100, "beach.jpg". The album's
// key is encrypted with the account's master key, the photo's key with the
// album's, and the photo's metadata with its own key. Only the session and
// these two records are needed: the tests never log in.
beforeAll(async () => {
await init();
await sodium.ready;
const masterKey = sodium.crypto_secretbox_keygen();
const keyPair = sodium.crypto_box_keypair();
paths.data = join(root, "session");
saveSession(paths.data, {
email: "log@example.com",
userID: USER_ID,
token: "token",
masterKey: toBase64(masterKey),
secretKey: toBase64(keyPair.privateKey),
publicKey: toBase64(keyPair.publicKey),
});
const albumKey = sodium.crypto_secretbox_keygen();
const albumKeyBox = encryptSecretbox(albumKey, masterKey);
const name = encryptSecretbox(new TextEncoder().encode("Trips"), albumKey);
const album = {
id: 1,
owner: { id: USER_ID },
encryptedKey: toBase64(albumKeyBox.ciphertext),
keyDecryptionNonce: toBase64(albumKeyBox.nonce),
encryptedName: toBase64(name.ciphertext),
nameDecryptionNonce: toBase64(name.nonce),
type: "album",
updationTime: 1,
};
const photoKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const photoKeyBox = encryptSecretbox(photoKey, albumKey);
const metadata = encryptStreamBlob(
new TextEncoder().encode(
JSON.stringify({
title: "beach.jpg",
fileType: 0,
creationTime: 1000,
modificationTime: 1000,
}),
),
photoKey,
);
const photo = {
id: 100,
collectionID: 1,
ownerID: USER_ID,
encryptedKey: toBase64(photoKeyBox.ciphertext),
keyDecryptionNonce: toBase64(photoKeyBox.nonce),
metadata: {
encryptedData: toBase64(metadata.ciphertext),
decryptionHeader: toBase64(metadata.header),
},
// These tests fetch no original and no thumbnail.
file: { decryptionHeader: "" },
thumbnail: { decryptionHeader: "" },
updationTime: 1,
};
const json = (body: unknown): Response =>
new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
});
vi.stubGlobal("fetch", async (input: string) => {
const { pathname } = new URL(input);
if (pathname === "/collections/v2") {
return json({ collections: [album] });
}
if (pathname === "/collections/v2/diff") {
return json({ diff: [photo], hasMore: false });
}
return new Response("not found", { status: 404 });
});
});
afterEach(() => {
process.argv = argv;
vi.restoreAllMocks();
});
afterAll(() => {
vi.unstubAllGlobals();
rmSync(root, { recursive: true, force: true });
});
// What `quak files` writes on stdout at every level.
const FILES = "100\timage\tbeach.jpg\n";
// What it writes on stderr at each level. Info is the refresh's progress, one
// line for the one album whose files it fetched. Debug adds the start and end
// of the refresh, and trace each request with the status of its answer.
const INFO = "Fetched the files of 1 of 1 collections\n";
const DEBUG = "Refresh started\n" + INFO + "Refresh done\n";
const TRACE =
"Refresh started\n" +
"GET https://api.ente.io/collections/v2?sinceTime=0: HTTP 200\n" +
"GET https://api.ente.io/collections/v2/diff?collectionID=1&sinceTime=0: HTTP 200\n" +
INFO +
"Refresh done\n";
describe("the log level", () => {
it("is info by default", async () => {
expect(await quak("files")).toEqual({
code: 0,
stdout: FILES,
stderr: INFO,
});
});
it("is debug with -v", async () => {
expect(await quak("-v", "files")).toEqual({
code: 0,
stdout: FILES,
stderr: DEBUG,
});
});
it("is trace with -vv", async () => {
expect(await quak("-vv", "files")).toEqual({
code: 0,
stdout: FILES,
stderr: TRACE,
});
});
it("stays at trace with -vvv", async () => {
expect((await quak("-vvv", "files")).stderr).toBe(TRACE);
});
it("counts --verbose --verbose as -vv", async () => {
expect((await quak("--verbose", "--verbose", "files")).stderr).toBe(
TRACE,
);
});
// Both are options of the program, taken before or after the command's
// name, as `--cache-dir` is.
it("is set by an option after the command's name too", async () => {
expect((await quak("files", "-v")).stderr).toBe(DEBUG);
expect((await quak("files", "--json", "-vv")).stderr).toBe(TRACE);
expect((await quak("files", "-q")).stderr).toBe("");
});
});
describe("-q", () => {
it("prints nothing on stderr when the command works", async () => {
expect(await quak("-q", "files")).toEqual({
code: 0,
stdout: FILES,
stderr: "",
});
});
// The refresh's progress is left out; the line saying why the command
// failed is the fatal one, and it is printed.
it("prints only the line a failing command exits with", async () => {
expect(await quak("-q", "files", "9")).toEqual({
code: 1,
stdout: "",
stderr: "Collection 9 not found\n",
});
});
it("is refused with -v, in one line, before the command runs", async () => {
for (const args of [
["-q", "-v", "files"],
["files", "--verbose", "--quiet"],
]) {
const { code, stdout, stderr } = await quak(...args);
expect(code).toBe(1);
expect(stdout).toBe("");
expect(stderr).toBe(
"error: option '-q, --quiet' cannot be used with option '-v, --verbose'\n",
);
}
});
});
// `--help` lists both options, and each command's help lists them too, since
// each command takes them.
describe("--help", () => {
it("lists -v and -q for the program and for each command", async () => {
for (const args of [["--help"], ["files", "--help"]]) {
const { stdout } = await quak(...args);
expect(stdout).toContain("-v, --verbose");
expect(stdout).toContain("-q, --quiet");
}
});
});
// The library writes no lines itself. Its layers report through callbacks,
// and the CLI logs what they report.
describe("what the library reports", () => {
const collect = (): { log: Log; lines: string[] } => {
const lines: string[] = [];
return { log: (level, line) => lines.push(`${level}: ${line}`), lines };
};
// A request answered 503 and retried: each attempt is a request, logged
// at trace with its status, and the retry is logged at debug with how
// long it waits and why.
it("logs each request at trace and each retry at debug", async () => {
const { log, lines } = collect();
const answers = [
new Response("unavailable", { status: 503 }),
new Response("{}", {
headers: { "content-type": "application/json" },
}),
];
const client = new ApiClient({
...logRequests(log, {
sleep: async () => {},
random: () => 0.5,
}),
fetch: async () => answers.shift()!,
});
await client.getJSON("/collections/v2", { sinceTime: 0 });
const url = "https://api.ente.io/collections/v2?sinceTime=0";
expect(lines).toEqual([
`trace: GET ${url}: HTTP 503`,
"debug: Attempt 1 failed, retrying in 250 ms: HTTP 503",
`trace: GET ${url}: HTTP 200`,
]);
});
it("logs a request that failed with its error", async () => {
const { log, lines } = collect();
const client = new ApiClient({
...logRequests(log, { attempts: 1 }),
fetch: async () => {
throw new TypeError("fetch failed", {
cause: new Error("connect ECONNREFUSED 1.2.3.4:443"),
});
},
});
await expect(client.getJSON("/collections/v2")).rejects.toThrow();
expect(lines).toEqual([
"trace: GET https://api.ente.io/collections/v2: fetch failed: connect ECONNREFUSED 1.2.3.4:443",
]);
});
// Each operation is named in plain words, as the README names it.
it("logs a refresh's progress at info, and its start and end at debug", () => {
const { log, lines } = collect();
const onProgress = logRefresh(log);
onProgress({ operation: "refresh", status: "started" });
onProgress({
operation: "refresh",
status: "progress",
fetched: 1,
total: 2,
});
onProgress({
operation: "refresh",
status: "failed",
error: "HTTP 503",
});
onProgress({ operation: "fetchMLData", status: "started" });
onProgress({ operation: "fetchMLData", status: "done", fetched: 3 });
expect(lines).toEqual([
"debug: Refresh started",
"info: Fetched the files of 1 of 2 collections",
"debug: Refresh failed: HTTP 503",
"debug: ML data fetch started",
"debug: ML data fetch done",
]);
});
});
+67 -8
View File
@@ -47,6 +47,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 { stderrLog, type LogLevel } from "../../src/cli-log.js";
import { import {
buildAccount, buildAccount,
buildMetaFetch, buildMetaFetch,
@@ -489,15 +490,18 @@ describe("quak backup-metadata", () => {
}); });
}); });
describe("quak backup-metadata when an ML data request fails", () => { describe("quak backup-metadata when a file's ML data cannot be read", () => {
// Run the CLI command against the mock and return its exit code, stderr // Run the CLI command against `fetch`, the mock unless given, logging at
// and output directory. // `level`, and return its exit code, stderr and output directory.
const runCommand = async (failMLDataFor?: number) => { const runCommand = async (
fetch = buildMetaFetch(mock),
level: LogLevel = "info",
) => {
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: buildMetaFetch(mock, failMLDataFor), fetch,
retry: { sleep: async () => {} }, retry: { sleep: async () => {} },
}, },
}); });
@@ -506,7 +510,10 @@ describe("quak backup-metadata when an ML data request fails", () => {
const code = await backupMetadataCommand( const code = await backupMetadataCommand(
{ {
stdout: { write: () => true }, stdout: { write: () => true },
stderr: { write: (text: string) => (stderr += text) }, log: stderrLog(
{ write: (text: string) => (stderr += text) },
level,
),
sessionDir: testDir, sessionDir: testDir,
cacheDir: mkdtempSync(join(testDir, "cache-")), cacheDir: mkdtempSync(join(testDir, "cache-")),
loadSession: () => client, loadSession: () => client,
@@ -517,11 +524,16 @@ describe("quak backup-metadata when an ML data request fails", () => {
return { code, stderr, outDir }; return { code, stderr, outDir };
}; };
// The failed request is logged at error, so it is the first line printed
// at that level.
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(200); const { code, stderr, outDir } = await runCommand(
buildMetaFetch(mock, 200),
"error",
);
expect(code).toBe(1); expect(code).toBe(1);
expect(stderr).toContain("ML data request for 1 file(s) failed"); expect(stderr).toMatch(/^ML data request for 1 file\(s\) failed: /);
const ok = JSON.parse( const ok = JSON.parse(
readFileSync( readFileSync(
@@ -543,6 +555,53 @@ describe("quak backup-metadata when an ML data request fails", () => {
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. That is logged at error, and
// the last line is fatal, so `-q`, which prints fatal lines only, still
// says why the command exits 1.
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),
},
},
}),
"error",
);
expect(code).toBe(1);
const reason = "secretstream chunk authentication failed";
expect(stderr).toBe(
`ML data of file 200 could not be read: ${reason}\n` +
`quak: the ML data of 1 file(s) could not be read; see mlDataError in their JSON in ${outDir}\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);
+48 -11
View File
@@ -3,10 +3,13 @@
* issue #36, issue #52). * issue #36, issue #52).
* *
* The `collections`, `files`, `get`, and `get-thumb` commands must answer for * The `collections`, `files`, `get`, and `get-thumb` commands must answer for
* current server state, not the local cache, so each helper forces a * current server state, not the local cache. `files`, `get` and `get-thumb`
* `Library.fresh()` round-trip before it reads. The stand-in library below * force a `Library.fresh()` round-trip before they read; the stand-in library
* serves nothing until `fresh()` has been awaited, so a helper that read * below serves nothing until `fresh()` has been awaited, so a helper that read
* without refreshing would come back empty and fail here. * without refreshing would come back empty and fail here. `collections` asks
* the library for the collection list alone (`freshCollections()`, issue
* #210), and must not wait for a refresh, which would fetch the files of every
* collection.
* *
* `collections` and `files` also list in the library's enumeration order * `collections` and `files` also list in the library's enumeration order
* (`listCollections`/`listFiles`) — the order the pre-library CLI printed — not * (`listCollections`/`listFiles`) — the order the pre-library CLI printed — not
@@ -60,10 +63,13 @@ const file = (
}); });
// A library that reveals its records only after `fresh()` has been awaited, and // A library that reveals its records only after `fresh()` has been awaited, and
// serves them in the enumeration order it was given. `photos.byID` returns a // serves them in the enumeration order it was given. `freshCollections()`
// stand-in `Photo` carrying just the fileID the helper passes through. // answers the collection list in that order without a refresh. `photos.byID`
// returns a stand-in `Photo` carrying just the fileID the helper passes
// through.
class FakeLibrary implements FreshReadLibrary { class FakeLibrary implements FreshReadLibrary {
freshCalls = 0; freshCalls = 0;
freshCollectionsCalls = 0;
private refreshed = false; private refreshed = false;
constructor( constructor(
@@ -77,12 +83,18 @@ class FakeLibrary implements FreshReadLibrary {
return {}; return {};
} }
listCollections(): Collection[] { async freshCollections(): Promise<Collection[]> {
return this.refreshed ? this.collections : []; this.freshCollectionsCalls++;
return this.collections;
} }
getCollection(id: number): Collection | undefined { getCollection(id: number): Collection | undefined {
return this.listCollections().find((c) => c.id === id); if (!this.refreshed) return undefined;
return this.collections.find((c) => c.id === id);
}
listCollections(): Collection[] {
return this.refreshed ? this.collections : [];
} }
listFiles(collectionID: number): EnteFile[] { listFiles(collectionID: number): EnteFile[] {
@@ -106,7 +118,7 @@ class FakeLibrary implements FreshReadLibrary {
} }
describe("CLI read helpers (issue #36 amendment, issue #52)", () => { describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
it("freshCollections refreshes first, then lists in enumeration order", async () => { it("freshCollections reads the collection list alone, in enumeration order", async () => {
// Enumeration order 2, 1, 3; a newest-first sort would be 3, 2, 1. // Enumeration order 2, 1, 3; a newest-first sort would be 3, 2, 1.
const lib = new FakeLibrary( const lib = new FakeLibrary(
[collection(2, 200), collection(1, 300), collection(3, 100)], [collection(2, 200), collection(1, 300), collection(3, 100)],
@@ -114,7 +126,8 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
); );
const rows = await freshCollections(lib); const rows = await freshCollections(lib);
expect(lib.freshCalls).toBe(1); expect(lib.freshCollectionsCalls).toBe(1);
expect(lib.freshCalls).toBe(0);
expect(rows.map((c) => c.id)).toEqual([2, 1, 3]); expect(rows.map((c) => c.id)).toEqual([2, 1, 3]);
// The projection's newest-first order is a different sequence, so this // The projection's newest-first order is a different sequence, so this
// is not accidentally that order. // is not accidentally that order.
@@ -166,6 +179,30 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
]); ]);
}); });
// Without a collection ID, `files` lists every file in the account once
// (issue #211). File 11 is in collections 1 and 2; collection 2 is
// enumerated first, so 11 is listed once, with collection 2's record.
it("freshFiles without a collection ID lists every file once, from the first collection holding it", async () => {
const files = [
file(10, 1, 1_700_000_000_000_000),
file(11, 1, 1_700_000_000_000_001),
file(20, 2, 1_700_000_000_000_002),
file(11, 2, 1_700_000_000_000_001),
];
const lib = new FakeLibrary(
[collection(2, 100), collection(1, 200)],
files,
);
const rows = await freshFiles(lib);
expect(lib.freshCalls).toBe(1);
expect(rows?.map((f) => [f.id, f.collectionID])).toEqual([
[20, 2],
[11, 2],
[10, 1],
]);
});
it("freshFiles returns undefined for an unknown collection", async () => { it("freshFiles returns undefined for an unknown collection", async () => {
const lib = new FakeLibrary([collection(1, 100)], []); const lib = new FakeLibrary([collection(1, 100)], []);
const rows = await freshFiles(lib, 999); const rows = await freshFiles(lib, 999);
+8 -1
View File
@@ -6,6 +6,7 @@ import { PassThrough } from "node:stream";
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { ApiError } from "../../src/api/client.js"; import { ApiError } from "../../src/api/client.js";
import { stderrLog } from "../../src/cli-log.js";
import { run } from "../../src/cli-run.js"; import { run } from "../../src/cli-run.js";
// A stream whose written text is kept in `text`; writes finish at once, so // A stream whose written text is kept in `text`; writes finish at once, so
@@ -23,7 +24,13 @@ const runToExit = async (
const stdout = collector(); const stdout = collector();
const stderr = collector(); const stderr = collector();
const code = await new Promise<number>((resolve) => { const code = await new Promise<number>((resolve) => {
void run(command, stdout.stream, stderr.stream, resolve); void run(
command,
stdout.stream,
stderr.stream,
stderrLog(stderr.stream, "info"),
resolve,
);
}); });
return { code, stdout: stdout.text(), stderr: stderr.text() }; return { code, stdout: stdout.text(), stderr: stderr.text() };
}; };
+89
View File
@@ -658,6 +658,26 @@ describe("downloadFile", () => {
expect(readdirSync(parent)).toEqual(["cwd"]); expect(readdirSync(parent)).toEqual(["cwd"]);
}); });
// A file system refuses a file name over 255 bytes.
it("cuts a title too long for a file name short, keeping its extension", async () => {
const plaintext = new Uint8Array([1, 2, 3]);
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { header, ciphertext } = encryptFileBody(plaintext, key);
const thumbPush =
sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
const file = buildMockEnteFile(key, header, thumbPush.header);
file.metadata.title = `${"t".repeat(296)}.jpg`;
const dir = mkdtempSync(join(testDir, "long-title-"));
const api = new ApiClient({ fetch: mockFetchForBody(ciphertext) });
const result = await inDirectory(dir, () => downloadFile(api, file));
const name = `${"t".repeat(251)}.jpg`;
expect(Buffer.byteLength(name)).toBe(255);
expect(result.path).toBe(name);
expect(readdirSync(dir)).toEqual([name]);
});
it("uses an explicit outPath verbatim, even one with ..", async () => { it("uses an explicit outPath verbatim, even one with ..", async () => {
// The caller is trusted: its path is not sanitized. // The caller is trusted: its path is not sanitized.
const { api, file } = fixtureFor( const { api, file } = fixtureFor(
@@ -751,6 +771,28 @@ describe("downloadThumbnail", () => {
expect(result.path).toBe("thumb__etc_passwd"); expect(result.path).toBe("thumb__etc_passwd");
expect(readdirSync(dir)).toEqual(["thumb__etc_passwd"]); expect(readdirSync(dir)).toEqual(["thumb__etc_passwd"]);
}); });
it("leaves room for thumb_ when it cuts a title too long for a file name short", async () => {
const plaintext = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const filePush =
sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
const { header: thumbHeader, ciphertext: thumbCipher } =
encryptFileBody(plaintext, key);
const file = buildMockEnteFile(key, filePush.header, thumbHeader);
file.metadata.title = `${"t".repeat(296)}.jpg`;
const dir = mkdtempSync(join(testDir, "long-thumb-title-"));
const api = new ApiClient({ fetch: mockFetchForBody(thumbCipher) });
const result = await inDirectory(dir, () =>
downloadThumbnail(api, file),
);
const name = `thumb_${"t".repeat(245)}.jpg`;
expect(Buffer.byteLength(name)).toBe(255);
expect(result.path).toBe(name);
expect(readdirSync(dir)).toEqual([name]);
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1881,6 +1923,53 @@ describe("downloadFile live photos", () => {
]); ]);
}); });
// A file system refuses a file name over 255 bytes. Each name is cut
// short once its own extension is in it.
const imageName = `${"t".repeat(250)}.heic`;
const videoName = `${"t".repeat(251)}.mov`;
// The title fits, but ".heic" is one byte longer than ".jpg".
it("cuts the image's name short for a 255-byte title ending in .jpg when no outPath is given", async () => {
const title = `${"t".repeat(251)}.jpg`;
const t = setup(livePhotoZip(), { ...livePhoto, title });
const result = await inDirectory(t.dir, () =>
downloadFile(t.api, t.file),
);
expect(Buffer.byteLength(title)).toBe(255);
expect(Buffer.byteLength(imageName)).toBe(255);
expect(Buffer.byteLength(videoName)).toBe(255);
expect(result.path).toBe(imageName);
expect(result.videoPath).toBe(videoName);
expect(readdirSync(t.dir).sort()).toEqual([imageName, videoName]);
});
it("cuts the names of the image and the video short for a 300-byte title with no extension when no outPath is given", async () => {
const t = setup(livePhotoZip(), {
...livePhoto,
title: "t".repeat(300),
});
const result = await inDirectory(t.dir, () =>
downloadFile(t.api, t.file),
);
expect(result.path).toBe(imageName);
expect(result.videoPath).toBe(videoName);
expect(readdirSync(t.dir).sort()).toEqual([imageName, videoName]);
});
// The caller's path is used as is; only the extension is each part's own.
it("keeps an outPath too long for a file name as given", async () => {
const t = setup(livePhotoZip(), livePhoto);
const outPath = join(t.dir, `${"t".repeat(251)}.jpg`);
await expect(downloadFile(t.api, t.file, outPath)).rejects.toThrow(
/ENAMETOOLONG/,
);
});
it("gives each part its own extension from the ZIP, letters and digits only", async () => { it("gives each part its own extension from the ZIP, letters and digits only", async () => {
const zip = livePhotoZip({ "image.JPG": IMAGE, "video.m-4v": VIDEO }); const zip = livePhotoZip({ "image.JPG": IMAGE, "video.m-4v": VIDEO });
const t = setup(zip, livePhoto); const t = setup(zip, livePhoto);
+136 -1
View File
@@ -14,7 +14,13 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { safeExtension, sanitizeFileName } from "../../src/filename.js"; import {
defaultFileName,
safeExtension,
sanitizeFileName,
shortenKeepingExtension,
shortenToBytes,
} from "../../src/filename.js";
const FALLBACK = "file-42"; const FALLBACK = "file-42";
@@ -90,4 +96,133 @@ describe("safeExtension", () => {
expect(safeExtension("x.jp g")).toBe(".bin"); expect(safeExtension("x.jp g")).toBe(".bin");
expect(safeExtension("x.jpg\0")).toBe(".bin"); expect(safeExtension("x.jpg\0")).toBe(".bin");
}); });
it("keeps an extension of 16 letters and digits, and uses .bin for a longer one", () => {
expect(safeExtension(`x.${"e".repeat(16)}`)).toBe(`.${"e".repeat(16)}`);
expect(safeExtension(`x.${"e".repeat(17)}`)).toBe(".bin");
expect(safeExtension(`b.${"e".repeat(300)}`)).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,
);
});
});
describe("shortenKeepingExtension", () => {
it("returns a name that fits unchanged", () => {
expect(shortenKeepingExtension("beach.jpg", 9)).toBe("beach.jpg");
expect(shortenKeepingExtension("beach", 5)).toBe("beach");
});
it("cuts the part before the extension short and keeps the extension", () => {
expect(shortenKeepingExtension(`${"t".repeat(296)}.jpg`, 255)).toBe(
`${"t".repeat(251)}.jpg`,
);
expect(shortenKeepingExtension("é".repeat(150), 255)).toBe(
"é".repeat(127),
);
});
// A name cut down to its extension would start with a dot, and so be
// hidden.
it("keeps the first character before the extension, cutting the extension short to leave room for it", () => {
expect(shortenKeepingExtension(`b.${"e".repeat(300)}`, 255)).toBe(
`b.${"e".repeat(253)}`,
);
expect(shortenKeepingExtension(`ab.${"e".repeat(300)}`, 255)).toBe(
`a.${"e".repeat(253)}`,
);
expect(shortenKeepingExtension(`é.${"e".repeat(300)}`, 255)).toBe(
`é.${"e".repeat(252)}`,
);
// "€" takes three bytes, so the extension is cut to 253 and the
// part before it keeps the two bytes left over, for the "a".
expect(shortenKeepingExtension(`a.${"€".repeat(90)}`, 255)).toBe(
`a.${"€".repeat(84)}`,
);
});
it("puts `insert` right before the extension and leaves room for it", () => {
expect(shortenKeepingExtension("beach.jpg", 255, " (7)")).toBe(
"beach (7).jpg",
);
expect(
shortenKeepingExtension(`${"t".repeat(296)}.jpg`, 255, " (7)"),
).toBe(`${"t".repeat(247)} (7).jpg`);
expect(
shortenKeepingExtension(`b.${"e".repeat(300)}`, 255, " (7)"),
).toBe(`b (7).${"e".repeat(249)}`);
});
});
describe("defaultFileName", () => {
it("is the title when it is safe and fits", () => {
expect(defaultFileName("IMG_0001.HEIC", 42)).toBe("IMG_0001.HEIC");
});
it("replaces unsafe characters, and is file-<id> for an empty title", () => {
expect(defaultFileName("../x.jpg", 42)).toBe("__x.jpg");
expect(defaultFileName("", 42)).toBe("file-42");
});
it("cuts a title over 255 bytes short, keeping its extension", () => {
expect(defaultFileName(`${"t".repeat(296)}.jpg`, 42)).toBe(
`${"t".repeat(251)}.jpg`,
);
expect(defaultFileName(`b.${"e".repeat(300)}`, 42)).toBe(
`b.${"e".repeat(253)}`,
);
});
it("puts `prefix` before the title and leaves room for it", () => {
expect(defaultFileName("beach.jpg", 42, { prefix: "thumb_" })).toBe(
"thumb_beach.jpg",
);
expect(
defaultFileName(`${"t".repeat(296)}.jpg`, 42, { prefix: "thumb_" }),
).toBe(`thumb_${"t".repeat(245)}.jpg`);
});
// A live photo's image and video are each named with their own
// extension, and the name is cut short once that extension is in it.
it("puts `ext` in place of the title's extension before cutting the name short", () => {
expect(defaultFileName("IMG_0001.HEIC", 42, { ext: ".mov" })).toBe(
"IMG_0001.mov",
);
expect(defaultFileName("", 42, { ext: ".mov" })).toBe("file-42.mov");
expect(
defaultFileName(`${"t".repeat(251)}.jpg`, 42, { ext: ".heic" }),
).toBe(`${"t".repeat(250)}.heic`);
expect(defaultFileName("t".repeat(300), 42, { ext: ".heic" })).toBe(
`${"t".repeat(250)}.heic`,
);
});
// Cutting `CONSOLE` or `NULL` short before a long extension leaves `CON`
// or `NUL`, so the name gets the `_` `sanitizeFileName` gives a device
// name, and the part before the extension gives up a byte for it.
it("never returns a Windows device name, even once cut short", () => {
const ext = `.${"e".repeat(251)}`;
expect(defaultFileName(`CONSOLE${ext}`, 42)).toBe(`_CO${ext}`);
expect(defaultFileName(`NULL${ext}`, 42)).toBe(`_NU${ext}`);
});
}); });
+70
View File
@@ -14,6 +14,9 @@
* three. * three.
* 3. A refresh that fails rejects the fresh read (currency was unavailable), * 3. A refresh that fails rejects the fresh read (currency was unavailable),
* while the default reads stay silent and keep serving the last good copy. * while the default reads stay silent and keep serving the last good copy.
* 4. `freshCollections()` reads the collection list alone, with one request
* and no collection's files, and stores nothing, so the next refresh still
* fetches the files of every collection (issue #210).
* *
* The client is the same metadata-only mock the background-refresh tests use: * The client is the same metadata-only mock the background-refresh tests use:
* no crypto, no network, scripted pages, and a record of each call. A long * no crypto, no network, scripted pages, and a record of each call. A long
@@ -270,3 +273,70 @@ describe("Library.fresh", () => {
} }
}); });
}); });
describe("Library.freshCollections", () => {
let dir: string;
let cacheDirectory: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "quak-fresh-collections-"));
cacheDirectory = join(dir, "cache");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
// A refresh fetches a collection's files only when the collection is new
// or its updationTime has advanced past the stored copy's. Had the
// collection list been stored, the next refresh would take every
// collection for one it already holds the files of, and fetch none.
it("asks for the collection list alone and stores nothing", async () => {
const client = new MockClient();
const page = {
collections: [collection(2, 100), collection(1, 110)],
deleted: [],
cursor: 110,
};
client.collectionsQueue.push(page, page);
client.filesFor(1, {
files: [file(1001, 1, 90)],
deleted: [],
cursor: 90,
});
client.filesFor(2, {
files: [file(2001, 2, 95)],
deleted: [],
cursor: 95,
});
const lib = await Library.open({
client,
cacheDirectory,
refreshOnOpen: false,
refreshIntervalSeconds: SLOW_INTERVAL,
});
try {
// One request, the whole list in the server's order, and no
// collection's files.
const collections = await lib.freshCollections();
expect(collections.map((c) => c.id)).toEqual([2, 1]);
expect(collections.map((c) => c.name)).toEqual([
"album-2",
"album-1",
]);
expect(client.collectionsSinceTimes).toEqual([0]);
expect(client.filesCalls).toEqual([]);
expect(lib.listCollections()).toEqual([]);
// The next refresh still asks from the beginning and fetches the
// files of both collections.
const reads = await lib.fresh();
expect(client.collectionsSinceTimes).toEqual([0, 0]);
expect(reads.photos.byID({ fileID: 1001 })?.fileID).toBe(1001);
expect(reads.photos.byID({ fileID: 2001 })?.fileID).toBe(2001);
} finally {
await lib.close();
}
});
});
+104
View File
@@ -26,6 +26,9 @@
* opening. * opening.
* 8. A save failure that leaves RAM ahead of disk keeps `status().lastError` * 8. A save failure that leaves RAM ahead of disk keeps `status().lastError`
* set and keeps retrying the write; a later empty refresh does not clear it. * set and keeps retrying the write; a later empty refresh does not clear it.
* 9. With `refreshOnOpen: false`, `open()` starts no refresh at all, and a
* refresh reports through `onProgress` each collection whose files it
* fetched.
* *
* The client is a mock: no crypto, no network. It serves scripted pages and * The client is a mock: no crypto, no network. It serves scripted pages and
* records the `sinceTime` each call carried so cursor threading is provable. * records the `sinceTime` each call carried so cursor threading is provable.
@@ -581,6 +584,107 @@ describe("Library.open and background refresh", () => {
} }
}); });
// A caller that asks for each refresh it needs, as the CLI's read commands
// do, opens without one: on an empty cache the refresh `open()` would
// await fetches the files of every collection, which `quak collections`
// never needs (issue #210).
it("starts no refresh as it opens when refreshOnOpen is false", async () => {
const client = new MockClient();
client.collectionsQueue.push({
collections: [collection(1, 100)],
deleted: [],
cursor: 100,
});
const lib = await Library.open({
client,
cacheDirectory,
refreshOnOpen: false,
refreshIntervalSeconds: Infinity,
});
try {
expect(client.collectionsSinceTimes).toEqual([]);
expect(lib.listCollections()).toEqual([]);
expect(lib.status().lastRefreshAt).toBeUndefined();
// The refresh comes when the caller asks for it.
await lib.fresh();
expect(client.collectionsSinceTimes).toEqual([0]);
expect(lib.listCollections().map((c) => c.id)).toEqual([1]);
} finally {
await lib.close();
}
});
// A refresh fetches the files of each collection that changed, one
// collection after another, and stores nothing until it has them all. On
// a large account that takes a while, so it reports each collection whose
// files it has fetched, and how many it will fetch in all (issue #210).
it("reports each collection whose files a refresh fetched", async () => {
const events: RefreshEvent[] = [];
const client = new MockClient();
client.collectionsQueue.push({
collections: [
collection(1, 100),
collection(2, 100),
collection(3, 100),
],
deleted: [],
cursor: 100,
});
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: Infinity,
onProgress: (e) => events.push(e),
});
try {
expect(events).toEqual([
{ operation: "refresh", status: "started" },
{
operation: "refresh",
status: "progress",
fetched: 1,
total: 3,
},
{
operation: "refresh",
status: "progress",
fetched: 2,
total: 3,
},
{
operation: "refresh",
status: "progress",
fetched: 3,
total: 3,
},
{ operation: "refresh", status: "done" },
]);
// Only collection 2 changed since, so the next refresh fetches
// its files alone and counts one.
events.length = 0;
client.collectionsQueue.push({
collections: [collection(2, 200)],
deleted: [],
cursor: 200,
});
await lib.fresh();
expect(events.filter((e) => e.status === "progress")).toEqual([
{
operation: "refresh",
status: "progress",
fetched: 1,
total: 1,
},
]);
} finally {
await lib.close();
}
});
it("keeps a save failure visible until a save actually succeeds", async () => { it("keeps a save failure visible until a save actually succeeds", async () => {
const saveSpy = vi const saveSpy = vi
.spyOn(MetadataStore.prototype, "save") .spyOn(MetadataStore.prototype, "save")
+150 -9
View File
@@ -12,9 +12,11 @@
* *
* 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. * later refreshes, and refetches a file whose `updationTime` advanced. A
* Opening a cache directory written by another account starts empty, * file whose ML data did not decode is reported with the reason, which
* its ML data included (issue #104). * stays listed until the next pass ends, and asked for again. Opening a
* 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.
@@ -26,7 +28,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 } from "../../src/library/index.js"; import { Library, type RefreshEvent } 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";
@@ -256,12 +258,14 @@ 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. // provable. A file in `mlErrorByFile` has ML data that did not decode, for
// 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 } {
@@ -298,14 +302,20 @@ class MLMockClient {
async fetchMLData(args: { async fetchMLData(args: {
fileIDs: number[]; fileIDs: number[];
fileKeys: Map<number, Uint8Array>; fileKeys: Map<number, Uint8Array>;
}): Promise<Map<number, MLData>> { }): Promise<{
payloads: Map<number, MLData>;
errors: Map<number, unknown>;
}> {
this.mlFetchCalls.push([...args.fileIDs]); this.mlFetchCalls.push([...args.fileIDs]);
const result = new Map<number, MLData>(); const payloads = 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) result.set(id, p); if (p) payloads.set(id, p);
const error = this.mlErrorByFile.get(id);
if (error) errors.set(id, error);
} }
return result; return { payloads, errors };
} }
filesFor(collectionID: number, ...pages: FilesPage[]): void { filesFor(collectionID: number, ...pages: FilesPage[]): void {
@@ -449,6 +459,137 @@ 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({
+54
View File
@@ -0,0 +1,54 @@
// `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"));
},
);
});
+115
View File
@@ -77,6 +77,14 @@ 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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -244,6 +252,82 @@ 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
@@ -471,6 +555,36 @@ describe("withRetry", () => {
expect(delays).toHaveLength(2); expect(delays).toHaveLength(2);
}); });
it("tells `onRetry` of each attempt it retries, before the wait", async () => {
// A caller that logs retries, as the CLI does with `-v`, learns why
// each attempt failed and how long the next one is put off. The last
// attempt is not retried, so it is not reported.
const { sleep, delays } = recordingSleep();
const retries: [string, number, number][] = [];
let calls = 0;
const failure = withRetry(
() => {
calls++;
return Promise.reject(new ApiError(`attempt ${calls}`, 503));
},
{
attempts: 3,
sleep,
random: () => 0.5,
onRetry: (err, attempt, delayMs) => {
retries.push([(err as Error).message, attempt, delayMs]);
},
},
);
await expect(failure).rejects.toThrow("attempt 3");
expect(retries).toEqual([
["attempt 1", 1, 250],
["attempt 2", 2, 500],
]);
expect(delays).toEqual([250, 500]);
});
it("does not retry at all when attempts is 1", async () => { it("does not retry at all when attempts is 1", async () => {
const { sleep, delays } = recordingSleep(); const { sleep, delays } = recordingSleep();
let calls = 0; let calls = 0;
@@ -624,6 +738,7 @@ describe("retry defaults", () => {
expect(resolved.maxDelayMs).toBe(DEFAULT_RETRY_OPTIONS.maxDelayMs); expect(resolved.maxDelayMs).toBe(DEFAULT_RETRY_OPTIONS.maxDelayMs);
expect(typeof resolved.sleep).toBe("function"); expect(typeof resolved.sleep).toBe("function");
expect(typeof resolved.random).toBe("function"); expect(typeof resolved.random).toBe("function");
expect(typeof resolved.onRetry).toBe("function");
}); });
it("resolves to the defaults when given nothing", () => { it("resolves to the defaults when given nothing", () => {
+26 -3
View File
@@ -544,11 +544,22 @@ describe("listMissingThumbnails", () => {
const client = await login(counted.fetch, { ...noWait }); const client = await login(counted.fetch, { ...noWait });
const lib = await openLib(client); const lib = await openLib(client);
const missing = await listMissingThumbnails(lib, client); const errors: string[] = [];
const missing = await listMissingThumbnails(
lib,
client,
(msg, level) => {
if (level === "error") errors.push(msg);
},
);
lib.close(); lib.close();
// Only the genuinely empty thumbnail is reported. // Only the genuinely empty thumbnail is reported. The file that
// could not be checked is reported as an error.
expect(missing.map((m) => m.fileID)).toEqual([101]); expect(missing.map((m) => m.fileID)).toEqual([101]);
expect(errors).toEqual([
expect.stringMatching(/^\[.*\] Could not check file-102\.jpg: /),
]);
// And the 500 was retried rather than accepted as an answer: four // And the 500 was retried rather than accepted as an answer: four
// attempts is the library default. // attempts is the library default.
expect(counted.matched()).toBe(4); expect(counted.matched()).toBe(4);
@@ -699,7 +710,15 @@ describe("fixMissingThumbnails", () => {
const client = await login(buildThumbFetch(fixMock)); const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client); const lib = await openLib(client);
const results = await fixMissingThumbnails(lib, client, [101, 102]); const errors: string[] = [];
const results = await fixMissingThumbnails(
lib,
client,
[101, 102],
(msg, level) => {
if (level === "error") errors.push(msg);
},
);
lib.close(); lib.close();
expect(results.length).toBe(2); expect(results.length).toBe(2);
@@ -707,6 +726,10 @@ describe("fixMissingThumbnails", () => {
const failure = results.find((r) => r.fileID === 102)!; const failure = results.find((r) => r.fileID === 102)!;
expect(success.status).toBe("fixed"); expect(success.status).toBe("fixed");
expect(failure.status).toBe("failed"); expect(failure.status).toBe("failed");
// The failed file, and only it, is reported as an error.
expect(errors).toEqual([
expect.stringMatching(/^\[.*\] FAILED file-102\.jpg: /),
]);
}); });
it("skips a non-JPEG image and a video as unsupported, not failed (issue #17)", async () => { it("skips a non-JPEG image and a video as unsupported, not failed (issue #17)", async () => {