1 Commits
Author SHA1 Message Date
sneak 129c648680 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 finished ML data
fetch in `status().mlDataErrors` and the `fetchMLData` "done" event.
`quak backup` writes the reason as `mlDataError` for a file with no ML
data cached and records the file in `failures.json`. `backup-metadata`
writes `mlDataError` for it and exits 1; `runMetadataBackup` returns
`filesWithMLDataError`.

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

Model: opus-5-5
2026-10-16 04:16:06 +00:00
22 changed files with 332 additions and 1634 deletions
+61 -128
View File
@@ -35,11 +35,8 @@ yarn quak login
# List the user's collections (albums). # List the user's collections (albums).
yarn quak collections yarn quak collections
# List every file in the account. # List files in a collection.
yarn quak files yarn quak files --collection 12345
# 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
@@ -162,11 +159,10 @@ After a `1`, the next run fetches each missing original again, fetches the ML
data that is not cached, and rebuilds the album links. An album quak could not data that is not cached, and rebuilds the album links. An album quak could not
decrypt, or a file in `failures.json` because quak could not decrypt it, keeps decrypt, or a file in `failures.json` because quak could not decrypt it, keeps
the exit code at `1` until the server sends a copy that decrypts, or deletes it the exit code at `1` until the server sends a copy that decrypts, or deletes it
(see "Backup layout"). An original that a `--verify` run could not read, or (see "Backup layout"). An original that a `--verify` run could not read, or put
could not replace with a download that matches, stays at its save path as it back with bytes that still do not match, stays at its save path. Only a later
was. Only a later `--verify` run checks it again: a run without `--verify` `--verify` run checks it again: a run without `--verify` leaves it as it is and
leaves it as it is and takes the file out of `failures.json`, so that run can takes the file out of `failures.json`, so that run can exit 0.
exit 0.
## Examples ## Examples
@@ -650,7 +646,7 @@ quak login interactive or QUAK_EM
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 [collectionID] [--json] list files in a collection, or every file quak files --collection <id> [--json] list files in a collection
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
@@ -664,67 +660,40 @@ 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. `collections` asks the server for the collection list alone, in one held. If that round-trip fails, the command prints the error on one line and
request, and neither fetches any collection's files nor changes the cache. The
others wait for a refresh. With an empty cache, as on a first run, that refresh
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 prints a line on stderr for each collection whose files it has
fetched, such as `Fetched the files of 3 of 120 collections`; with `--json`,
`files` and the two helpers print no such line. 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 exits 1, or 3 when the server no longer accepts the saved session (see "Session
handling"). An album or file quak cannot decrypt does not fail it; the command handling"). An album or file quak cannot decrypt does not fail it; the command
answers from what did decrypt (see "Default reads vs. fresh reads"). 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.
`files <collectionID>` lists the files in that collection. `files` with no An ID given to `files --collection`, `get`, `get-thumb` or
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`), and each name is Ente's clients name them (`IMG_0001.heic` and `IMG_0001.mov`). With
cut short as above once its own extension is in it. With `--out PATH`, the image `--out PATH`, the image is written to `PATH` and the video beside it, with
is written to `PATH` and the video beside it, with `PATH`'s name and the video's `PATH`'s name and the video's extension; a `PATH` with the video's extension is
extension; a `PATH` with the video's extension is refused. refused. `backup-metadata --exif` (alias `--all`) additionally fetches each
`backup-metadata --exif` (alias `--all`) additionally fetches each file's file's original through the cache and records, from it or a live photo's image,
original through the cache and records, from it or a live photo's image, its XMP its XMP metadata, its EXIF metadata and, for a JPEG, its dimensions. EXIF is
metadata, its EXIF metadata and, for a JPEG, its dimensions. EXIF is read with read with [exifreader](https://github.com/mattiasw/ExifReader) from any image
[exifreader](https://github.com/mattiasw/ExifReader) from any image format it format it reads, JPEG, HEIC/HEIF, AVIF, PNG and WebP among them. The record's
reads, JPEG, HEIC/HEIF, AVIF, PNG and WebP among them. The record's `exif` field `exif` field is exifreader's EXIF tag output: each tag by name, with its
is exifreader's EXIF tag output: each tag by name, with its `value`, `value`, `description` and `computed` value. An EXIF block exifreader finds but
`description` and `computed` value. An EXIF block exifreader finds but reads no reads no tag from is recorded, base64, as `exifRaw`, with the reason in
tag from is recorded, base64, as `exifRaw`, with the reason in `exifError`. `exifError`. `collections`, `files`, `backup`, `helper list-missing-thumbnails`
`collections`, `files`, `backup`, `helper list-missing-thumbnails` and and `helper fix-missing-thumbnails` take `--json` for machine-readable output.
`helper fix-missing-thumbnails` take `--json` for machine-readable output.
`backup --verify` also hashes the originals already in the backup and replaces `backup --verify` also hashes the originals already in the backup and replaces
any that do not match the content hash Ente records; one it cannot replace stays any that do not match the content hash Ente records; one it cannot replace goes
as it was and goes into `failures.json` (see "Backup layout"). into `failures.json` (see "Backup layout").
`backup-metadata <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
@@ -778,10 +747,9 @@ 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, it holds anything but name as uploaded, case kept, or `.bin` when it has none or it holds anything but
letters and digits, or it is more than 16 of them long. When the date or the letters and digits. When the date or the time zone changes, the next run saves
time zone changes, the next run saves the original at its new path and leaves the original at its new path and leaves the old copy where it is.
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`,
@@ -801,11 +769,8 @@ and the next run fetches it again. The same goes for a file with no ML data in
the cache whose ML data the server sent but quak could not decrypt, gunzip or the cache whose ML data the server sent but quak could not decrypt, gunzip or
parse. Its `mlDataError` says why, and `failures.json` records it with an error parse. Its `mlDataError` says why, and `failures.json` records it with an error
starting `ML data: `. quak keeps that reason in memory only and caches nothing starting `ML data: `. quak keeps that reason in memory only and caches nothing
for the file, so the next run asks for its ML data again. An ML data fetch that for the file, so the next run asks for its ML data again. The JSON files are
fails, such as one the library's refresh starts while the backup writes the JSON rewritten on every run, so ML data that arrived since the last run appears.
files, leaves the reasons as the last fetch that succeeded left them. The JSON
files are rewritten on every run, so ML data that arrived since the last run
appears.
A file's JSON holds, as `imageMetadata`, what `backup-metadata --exif` records A file's JSON holds, as `imageMetadata`, what `backup-metadata --exif` records
from its original, or from a live photo's image: `format`, `width` and `height` from its original, or from a live photo's image: `format`, `width` and `height`
@@ -874,12 +839,11 @@ 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 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 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, 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 just far enough that it fits. When a symlink's extension alone leaves no room
ID and the first character of the name, the extension is cut short, and the part for the ID, the extension is cut short and the part before the ID keeps only the
before the ID keeps that character and any bytes the cut leaves over. A bytes that cut leaves over. A collection's directory name also leaves room for
collection's directory name also leaves room for the `.json` its JSON adds. IDs the `.json` its JSON adds. IDs never change, so a name stays the same from run
never change, so a name stays the same from run to run until such a clash to run until such a clash appears or goes away.
appears or goes away.
A collection whose directory or JSON cannot be written, such as one whose 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 directory name is taken by a file you put in `collections/`, does not stop the
@@ -920,41 +884,33 @@ original already at its save path the way a download is checked (see "On-disk
cache layout" below): its bytes, read in chunks, or a live photo's image and cache layout" below): its bytes, read in chunks, or a live photo's image and
video, joined as `<imageHash>:<videoHash>`. An original that matches the content video, joined as `<imageHash>:<videoHash>`. An original that matches the content
hash its metadata records is left as it is. One that does not is logged on one hash its metadata records is left as it is. One that does not is logged on one
line naming the file, and replaced once a matching copy is downloaded, in the line naming the file, deleted (a live photo's image and video both), and put
same run. The copy is downloaded even when the cache holds the original, because back in the same run like a missing one: downloaded, or copied from the cache if
a copy from the cache is not checked as a download is. It is written to a the cache holds it. What is put back is hashed too, because a copy from the
temporary file beside the original, a live photo's image and video each to its cache is not checked as a download is. If it still does not match, it stays at
own, hashed the same way, and renamed over the original only when it matches; a its save path and the file goes into `failures.json`, as it does when the
live photo's image and video are both hashed before either is renamed. Until download fails. A file whose metadata records no hash is left as it is and
then the original stays as it was: when the download fails, or what it brings counted as unchecked. A stored original that cannot be read is left as it is and
does not match either, the temporary files are deleted, the original keeps its counts as failed. The summary and `--json` add the counts `verified`,
bytes and its album links, and the file goes into `failures.json`. A run that `mismatched` and `unchecked`, all of originals that were already stored; one
stops between renaming a live photo's new image and its new video leaves the new first downloaded in this run is in none of them. A mismatch that was put back
image beside the old video, which a later `--verify` run replaces if the video with matching bytes does not make the exit code non-zero. Without `--verify`
was the part that did not match. A file whose metadata records no hash is left nothing is hashed, the summary is unchanged, and the three counts are 0 in
as it is and counted as unchecked. A stored original that cannot be read is left `--json`.
as it is and counts as failed. The summary and `--json` add the counts
`verified`, `mismatched` and `unchecked`, all of originals that were already
stored; one first downloaded in this run is in none of them. A mismatch that was
replaced with matching bytes does not make the exit code non-zero. Without
`--verify` nothing is hashed, the summary is unchanged, and the three counts are
0 in `--json`.
Each original is written to a temporary file in the same directory, synced to Each original is written to a temporary file in the same directory, synced to
disk, and renamed into place, so an original is either complete or absent, even disk, and renamed into place, so an original is either complete or absent, even
after a power cut. A downloaded original's temporary file is named after a power cut. A downloaded original's temporary file is named
`.quak-<pid>-<random>.tmp`, one copied from the cache `.quak-<pid>-<random>.tmp`, one copied from the cache
`.quak-backup-YYYY-MM-DD.<fileID>.<ext>-<pid>-<random>.tmp`. A copy that `.quak-backup-YYYY-MM-DD.<fileID>.<ext>-<pid>-<random>.tmp`. A run that is
`--verify` downloads to replace an original is written to such a killed can leave one of these temporary files behind; the next backup deletes
`.quak-<pid>-<random>.tmp` too, a live photo's image and video to those whose process is no longer running. The content cache uses the same
`.quak-<pid>-<random>.<ext>`, each with its own extension. A run that is killed scheme, and opening a library deletes the temporary files in the cache whose
can leave one of these temporary files behind; the next backup deletes those process is no longer running, so a download another process has in progress in
whose process is no longer running. The content cache uses the same scheme, and the same cache is left alone. The rename replaces whatever was at the
opening a library deletes the temporary files in the cache whose process is no destination rather than writing through it: a symlink there is replaced, not
longer running, so a download another process has in progress in the same cache followed, and the new file has the temporary file's permissions, not those of
is left alone. The rename replaces whatever was at the destination rather than the file it replaced.
writing through it: a symlink there is replaced, not followed, and the new file
has the temporary file's permissions, not those of the file it replaced.
## TODO ## TODO
@@ -993,11 +949,7 @@ 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. With background, so an unreachable server does not block opening.
`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`:
@@ -1007,7 +959,6 @@ it so (see "CLI surface").
| `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 |
@@ -1021,14 +972,6 @@ it so (see "CLI surface").
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).
@@ -1042,12 +985,11 @@ the precache fetches already running have all finished, so the cache directory
can then be removed. can then be removed.
`lib.status().mlDataErrors` lists, as `{ fileID, error }`, each file whose ML `lib.status().mlDataErrors` lists, as `{ fileID, error }`, each file whose ML
data the last ML data fetch that succeeded got but could not decrypt, gunzip or data the last ML data fetch to finish got but could not decrypt, gunzip or
parse; the `fetchMLData` event with status `"done"` carries the same list. A 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 fetch that is still running leaves the list as it is until it ends. Such ML data
that fails leaves it as it was. Such ML data is not cached, so the next ML data is not cached, so the next ML data fetch asks for it again. The reasons are kept
fetch asks for it again. The reasons are kept in memory only, never in the in memory only, never in the cache.
cache.
### Default reads vs. fresh reads ### Default reads vs. fresh reads
@@ -1068,15 +1010,6 @@ rejects the caller (default reads stay silent and keep serving the last good
copy). The CLI's read commands use fresh reads (issue copy). The CLI's read commands use fresh reads (issue
https://git.eeqj.de/sneak/quak/issues/75). https://git.eeqj.de/sneak/quak/issues/75).
`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
+9 -53
View File
@@ -17,8 +17,8 @@ pre-1.0
# Next Step # Next Step
No bug is open. One change sneak asked for is open: Two bugs are open: https://git.eeqj.de/sneak/quak/issues/199 and
https://git.eeqj.de/sneak/quak/issues/212. The check of quak against a live Ente https://git.eeqj.de/sneak/quak/issues/205. The check of quak against a live Ente
account, https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak account, https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak
has, so it waits on him. has, so it waits on him.
@@ -27,61 +27,17 @@ declares one.
# Completed Steps # Completed Steps
- 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 - 2026-10-16: ML data the server sends that does not decrypt, gunzip or parse is
no longer taken for no ML data (issue 198). `fetchMLDataBatch` returns why for no longer taken for no ML data (issue 198). `fetchMLDataBatch` returns why for
each such file beside the payloads; ML data that parses to something other each such file beside the payloads; ML data that parses to something other
than a JSON object, such as `null`, is one of them. The library keeps the 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 reasons from its last ML data fetch to finish in memory, not in the cache, and
cache, and gives them in `status().mlDataErrors` and in the `fetchMLData` gives them in `status().mlDataErrors` and in the `fetchMLData` event with
event with status `"done"`; a fetch still running or one that fails does not status `"done"`; a fetch still running does not change them. `quak backup`
change them. `quak backup` writes the reason as `mlDataError` in the file's writes the reason as `mlDataError` in the file's JSON when no ML data is
JSON when no ML data is cached for it, and records the file in `failures.json` cached for it, and records the file in `failures.json` with an error starting
with an error starting `ML data: `; `backup-metadata` writes `mlDataError` `ML data: `; `backup-metadata` writes `mlDataError` too. Both exit 1. Tests
too. Both exit 1. Tests serve ML data encrypted under a key that is not the serve ML data encrypted under a key that is not the file's.
file's.
- 2026-10-16: With `--verify`, an original whose hash does not match is no
longer removed before its replacement is downloaded (issue 199).
`replaceOriginal` in `src/library/content.ts` downloads the replacement to a
temporary file beside it, a live photo's image and video each to its own,
hashes it and renames it over the original only when it matches. When the
download fails or does not match either, the original keeps its bytes and
links, and the file goes into `failures.json`. The replacement is always
downloaded, through the new `fetchOriginal` of the content cache, never copied
from the cache.
- 2026-10-16: The `~/bin/quak` that `make install` builds retries a refused - 2026-10-16: The `~/bin/quak` that `make install` builds retries a refused
connection and a name that does not resolve, and the backup records either as connection and a name that does not resolve, and the backup records either as
+6 -7
View File
@@ -78,15 +78,14 @@ program
program program
.command("files") .command("files")
.description("List the files in a collection, or every file in the account") .description("List files in a collection")
.argument( .requiredOption(
"[collectionID]", "--collection <id>",
"Collection ID (from `quak collections`); without it, every file " + "Collection ID (from `quak collections`)",
"is listed once",
) )
.option("--json", "Output as JSON array") .option("--json", "Output as JSON array")
.action((collectionID: string | undefined, opts: { json?: boolean }) => .action((opts: { collection: string; json?: boolean }) =>
run(filesCommand(context(), collectionID, opts)), run(filesCommand(context(), opts)),
); );
program program
+68 -47
View File
@@ -41,9 +41,8 @@
// //
// With `verify`, each original already at its save path is hashed as the // With `verify`, each original already at its save path is hashed as the
// download check hashes it, and one that does not match the content hash its // download check hashes it, and one that does not match the content hash its
// metadata records is replaced once a matching copy is downloaded, in the same // metadata records is removed and fetched again in the same run. What is put
// run. Until then it stays as it is: a download that fails, or that does not // back is hashed too, and recorded as failed if it still does not match.
// match either, leaves it in place and is recorded as failed.
// //
// Resilience (issue #8): no per-file condition aborts the run. A failed // Resilience (issue #8): no per-file condition aborts the run. A failed
// download, a failed symlink, or ML data missing because the ML data fetch // download, a failed symlink, or ML data missing because the ML data fetch
@@ -62,6 +61,7 @@
// code. // code.
import { import {
createReadStream,
lstatSync, lstatSync,
mkdirSync, mkdirSync,
readdirSync, readdirSync,
@@ -77,22 +77,25 @@ import { readFile } from "node:fs/promises";
import { dirname, extname, join, relative, resolve } from "node:path"; import { dirname, extname, join, relative, resolve } from "node:path";
import lockfile from "proper-lockfile"; import lockfile from "proper-lockfile";
import {
chunkHashFinal,
chunkHashInit,
chunkHashUpdate,
init,
} from "./crypto/index.js";
import { removeLeftoverTempFiles } from "./download/index.js"; import { removeLeftoverTempFiles } from "./download/index.js";
import { describeError } from "./errors.js"; import { describeError } from "./errors.js";
import { import {
MAX_NAME_BYTES, MAX_NAME_BYTES,
sanitizeFileName, sanitizeFileName,
shortenKeepingExtension,
shortenToBytes, shortenToBytes,
withExtension, withExtension,
} from "./filename.js"; } 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";
@@ -149,9 +152,9 @@ export interface BackupResult {
// Originals already at their save path and left untouched. // Originals already at their save path and left untouched.
skipped: number; skipped: number;
// With `verify`, the originals already at their save path whose hash // With `verify`, the originals already at their save path whose hash
// matched, those whose hash did not (each replaced once a matching copy is // matched, those whose hash did not (each removed and fetched again), and
// downloaded), and those whose metadata records no hash (left as they // those whose metadata records no hash (left as they are). All three are
// are). All three are zero without `verify`. // zero without `verify`.
verified: number; verified: number;
mismatched: number; mismatched: number;
unchecked: number; unchecked: number;
@@ -189,15 +192,6 @@ export interface BackupLibrary {
fileID: number, fileID: number,
destination: string, destination: string,
): Promise<{ path: string; videoPath?: string }>; ): Promise<{ path: string; videoPath?: string }>;
// Fetch an original's bytes to `destination` through the content pool,
// a live photo as its image and its video beside it, even when the cache
// or the library's save path already holds it, and return where they
// landed. With `verify`, an original whose hash does not match is replaced
// with what this fetches.
fetchOriginal(
fileID: number,
destination: string,
): Promise<{ path: string; videoPath?: string }>;
thumbnail(fileID: number): Promise<{ path: string }>; thumbnail(fileID: number): Promise<{ path: string }>;
// How many originals the backup works on at once: the limit of the // How many originals the backup works on at once: the limit of the
// library's content pool. // library's content pool.
@@ -209,10 +203,9 @@ export interface BackupLibrary {
// A file's cached ML data, as `lib.mldata.forFile()` returns it, or // A file's cached ML data, as `lib.mldata.forFile()` returns it, or
// undefined when none is cached. // undefined when none is cached.
mlData(fileID: number): Promise<MLData | undefined>; mlData(fileID: number): Promise<MLData | undefined>;
// Why the ML data the last ML data fetch that succeeded got for a file did // Why the ML data the last ML data fetch to finish got for a file did not
// not decrypt, gunzip or parse, or undefined when it did or the fetch got // 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 // none. A fetch still running does not change it.
// leaves it as it was.
mlDataError(fileID: number): unknown; mlDataError(fileID: number): unknown;
} }
@@ -290,10 +283,9 @@ const rebuildSymlink = (linkPath: string, target: string): void => {
// case (two names that differ only in case are one entry on a case-insensitive // case (two names that differ only in case are one entry on a case-insensitive
// file system), or it is too long for a file name; then it gets ` (<id>)`, // file system), or it is too long for a file name; then it gets ` (<id>)`,
// right before the extension when `beforeExtension` is set. To fit, the part // right before the extension when `beforeExtension` is set. To fit, the part
// before the ID is cut short as far as it must be. When the extension leaves // before the ID is cut short as far as it must be. When the extension alone
// no room for the ID and the first character of the name, the extension is // leaves no room for the ID, the extension is cut short and the part before
// cut short, and the part before the ID keeps that character and any bytes // the ID keeps only the bytes that cut leaves over. With `withJSON`, each
// the cut leaves over (see `shortenKeepingExtension`). With `withJSON`, each
// entry also has the JSON file `<name>.json` beside it, as an album does: a // 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 // name then leaves room for `.json`, and one that is another entry's JSON name
// gets its ID added too. // gets its ID added too.
@@ -307,9 +299,11 @@ const uniqueNames = (
const maxBytes = MAX_NAME_BYTES - (opts.withJSON ? ".json".length : 0); const maxBytes = MAX_NAME_BYTES - (opts.withJSON ? ".json".length : 0);
const withID = (id: number, name: string): string => { const withID = (id: number, name: string): string => {
const tag = ` (${id})`; const tag = ` (${id})`;
return opts.beforeExtension const fullExt = opts.beforeExtension ? extname(name) : "";
? shortenKeepingExtension(name, maxBytes, tag) const stem = name.slice(0, name.length - fullExt.length);
: shortenToBytes(name, maxBytes - Buffer.byteLength(tag)) + tag; const ext = shortenToBytes(fullExt, maxBytes - Buffer.byteLength(tag));
const room = maxBytes - Buffer.byteLength(tag + ext);
return shortenToBytes(stem, room) + tag + ext;
}; };
const names = entries.map((e) => e.name); const names = entries.map((e) => e.name);
const suffixed = new Set<number>(); const suffixed = new Set<number>();
@@ -454,6 +448,26 @@ const saveLedger = (path: string, ledger: Map<number, FailureEntry>): void => {
); );
}; };
// The content hash of the original stored at `stored`, computed as the download
// check computes it: over each file's bytes, read in chunks, and for a live
// photo `<imageHash>:<videoHash>`.
const storedHash = async (stored: {
path: string;
videoPath?: string;
}): Promise<string> => {
await init();
const hashFile = async (path: string): Promise<string> => {
const state = chunkHashInit();
for await (const chunk of createReadStream(path)) {
chunkHashUpdate(state, chunk as Buffer);
}
return chunkHashFinal(state);
};
const hash = await hashFile(stored.path);
if (stored.videoPath === undefined) return hash;
return `${hash}:${await hashFile(stored.videoPath)}`;
};
// A file's EXIF, XMP and dimensions as its JSON holds them: what // A file's EXIF, XMP and dimensions as its JSON holds them: what
// `extractImageMetadata` found in its original, or why the original could not // `extractImageMetadata` found in its original, or why the original could not
// be read. // be read.
@@ -675,13 +689,11 @@ const runLockedBackup = async (
// through the content cache/pools, as `Photo.download()` does, and fetch // through the content cache/pools, as `Photo.download()` does, and fetch
// the optional thumbnails; a present file is left as is. With `verify`, a // the optional thumbnails; a present file is left as is. With `verify`, a
// present original is hashed first, and one that does not match the hash // present original is hashed first, and one that does not match the hash
// its metadata records is downloaded again and replaced by // its metadata records is removed and fetched like a missing one. One
// `replaceOriginal`, which leaves it as it was when that download fails // that cannot be read is recorded as failed and left where it is.
// or does not match either. One that cannot be read is recorded as failed // `storeOriginal` does this for one original, and records its failure.
// and left where it is. `storeOriginal` does this for one original, and
// records its failure.
const storeOriginal = async (file: EnteFile): Promise<void> => { const storeOriginal = async (file: EnteFile): Promise<void> => {
const stored = storedAtSavePath(downloadDirectory, file); let stored = storedAtSavePath(downloadDirectory, file);
let mismatch = false; let mismatch = false;
if (stored !== undefined && verify) { if (stored !== undefined && verify) {
try { try {
@@ -695,6 +707,11 @@ const runLockedBackup = async (
); );
mismatched++; mismatched++;
mismatch = true; mismatch = true;
rmSync(stored.path);
if (stored.videoPath !== undefined) {
rmSync(stored.videoPath);
}
stored = undefined;
} }
} catch (err) { } catch (err) {
log( log(
@@ -709,25 +726,29 @@ const runLockedBackup = async (
return; return;
} }
} }
if (stored !== undefined && !mismatch) { if (stored !== undefined) {
skipped++; skipped++;
return; return;
} }
try { try {
log(`Fetching original ${file.metadata.title} (${file.id})...`); log(`Fetching original ${file.metadata.title} (${file.id})...`);
if (mismatch) { // A fetched original is written straight to its save path (a
await replaceOriginal(downloadDirectory, file, (dest) => // live photo beside it); only one that was already cached
lib.fetchOriginal(file.id, dest), // elsewhere is copied.
); const placed = await placeOriginal(
} else { downloadDirectory,
// A fetched original is written straight to its save path (a file,
// live photo beside it); only one that was already cached (dest) => lib.original(file.id, dest),
// elsewhere is copied. );
await placeOriginal(downloadDirectory, file, (dest) => storedThisRun.add(file.id);
lib.original(file.id, dest), // A copy from the cache is not checked as a download is and can
// hold the same bad bytes, so what is put back after a mismatch
// is hashed too. A bad copy stays where it is and the file fails.
if (mismatch && (await storedHash(placed)) !== file.metadata.hash) {
throw new Error(
"the original put back does not match its content hash either",
); );
} }
storedThisRun.add(file.id);
downloaded++; downloaded++;
} catch (err) { } catch (err) {
log( log(
+17 -42
View File
@@ -125,29 +125,15 @@ 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 no refresh starts as it opens or on a // downloading the whole account — and the refresh interval is long so no second
// timer: each command asks for the one it needs, and `collections` needs none // refresh fires mid-command.
// (issue #210). While a refresh runs, each collection whose files it has const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> =>
// fetched is counted on stderr, unless `json` is set, so a large account shows
// progress rather than nothing.
const openReadLibrary = (
ctx: CliContext,
client: Client,
opts: { json?: boolean } = {},
): Promise<Library> =>
Library.open({ Library.open({
client: readLibraryClient(client), client: readLibraryClient(client),
cacheDirectory: ctx.cacheDir, cacheDirectory: ctx.cacheDir,
refreshOnOpen: false, refreshIntervalSeconds: 3600,
refreshIntervalSeconds: Infinity,
precacheThumbnails: false, precacheThumbnails: false,
precacheOriginals: false, precacheOriginals: false,
onProgress: (event) => {
if (opts.json || event.status !== "progress") return;
ctx.stderr.write(
`Fetched the files of ${event.fetched} of ${event.total} collections\n`,
);
},
}); });
export const loginCommand = async (ctx: CliContext): Promise<number> => { export const loginCommand = async (ctx: CliContext): Promise<number> => {
@@ -235,10 +221,9 @@ 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 {
// Ask the server for the collection list alone and list it in its // Force a server round-trip and list in enumeration order (issue #36
// order (issue #36 amendment, issues #52 and #210): the pre-library // amendment, issue #52): the pre-library CLI printed current state in
// CLI printed current state in this order, not the albums // this order, not the albums projection's newest-first order.
// projection's newest-first order.
const collections = await freshCollections(lib); const collections = await freshCollections(lib);
if (opts.json) { if (opts.json) {
@@ -269,23 +254,17 @@ 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,
collectionIDStr: string | undefined, opts: { collection: string; json?: boolean },
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 = const collectionID = parseID(ctx, "collection", opts.collection);
collectionIDStr === undefined if (collectionID === undefined) return 1;
? undefined
: parseID(ctx, "collection", collectionIDStr);
if (collectionIDStr !== undefined && collectionID === undefined) return 1;
const lib = await openReadLibrary(ctx, client, opts); const lib = await openReadLibrary(ctx, client);
try { try {
// Force a server round-trip and list in enumeration order (issue #36 // Force a server round-trip and list in enumeration order (issue #36
// amendment, issue #52). Each file prints from its own decrypted // amendment, issue #52). Each file prints from its own decrypted
@@ -338,8 +317,8 @@ 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.stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
return 0; return 0;
@@ -347,13 +326,9 @@ export const getCommand = async (
// 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 imageExt = extname(result.path); const imageOut =
const videoExt = extname(result.videoPath); opts.out ?? withExtension(outPath, extname(result.path));
const imageOut = opts.out ?? originalName(file, imageExt); const videoOut = withExtension(outPath, extname(result.videoPath));
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.stderr.write(
`File ${fileID} is a live photo, and its video would also be written to ${imageOut}\n`, `File ${fileID} is a live photo, and its video would also be written to ${imageOut}\n`,
@@ -515,7 +490,7 @@ export const listMissingThumbnailsCommand = async (
await init(); await init();
const client = requireSession(ctx); const client = requireSession(ctx);
if (!client) return 3; if (!client) return 3;
const lib = await openReadLibrary(ctx, client, opts); const lib = await openReadLibrary(ctx, client);
try { try {
// 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.
@@ -560,7 +535,7 @@ export const fixMissingThumbnailsCommand = async (
if (fileID === undefined) return 1; if (fileID === undefined) return 1;
fileIDs.push(fileID); fileIDs.push(fileID);
} }
const lib = await openReadLibrary(ctx, client, opts); const lib = await openReadLibrary(ctx, client);
try { try {
// Refresh first so files added since the cache was written are found; // Refresh first so files added since the cache was written are found;
// a failed refresh throws. // a failed refresh throws.
+7 -9
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 { defaultFileName } from "./filename.js"; import { sanitizeFileName } 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,13 +33,11 @@ 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, made from the // Default output path for `quak get` when `--out` is not given. The title comes
// title (see `defaultFileName`), with `ext` in place of its extension for a // from the server, so it is sanitized; `--out` is the user's and is used as is.
// live photo's image or video; `--out` is the user's and is used as is. export const originalName = (file: EnteFile): string =>
export const originalName = (file: EnteFile, ext?: string): string => sanitizeFileName(file.metadata.title, `file-${file.id}`);
defaultFileName(file.metadata.title, file.id, { ext });
// Default output path for `quak get-thumb` when `--out` is not given: `thumb_` // Default output path for `quak get-thumb` when `--out` is not given.
// and the title.
export const thumbnailName = (file: EnteFile): string => export const thumbnailName = (file: EnteFile): string =>
defaultFileName(file.metadata.title, file.id, { prefix: "thumb_" }); `thumb_${originalName(file)}`;
+19 -41
View File
@@ -1,72 +1,50 @@
// How the CLI's read commands obtain current data. // How the CLI's read commands obtain current data.
// //
// `collections`, `files`, `get`, and `get-thumb` must answer for // `collections`, `files --collection`, `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). `files`, // local cache last happened to hold (owner amendment, issue #36). Each helper
// `get` and `get-thumb` therefore force a server round-trip through // therefore forces a server round-trip through `Library.fresh()` and only then
// `Library.fresh()` and only then read — so a collection, file, or metadata // reads — so a collection, file, or metadata change made elsewhere is visible.
// 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` lists in the server's order and `files` in the library's own // `collections` and `files` also list in the library's own enumeration order —
// enumeration order (`listFiles()`), the orders the pre-library CLI printed, // `listCollections()`/`listFiles()`, the order 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`. `files` without a collection ID lists every // metadata via `cli-output.ts`.
// 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()` and `freshCollections()` calls and serves records in a known // `fresh()` call and serves records in a known enumeration order.
// enumeration order.
export interface FreshReadLibrary { export interface FreshReadLibrary {
fresh(): Promise<unknown>; fresh(): Promise<unknown>;
freshCollections(): Promise<Collection[]>;
getCollection(id: number): Collection | undefined;
listCollections(): Collection[]; listCollections(): Collection[];
getCollection(id: number): Collection | undefined;
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 one collections request, in the // Every live collection, current as of a forced refresh, in enumeration order.
// server's order. export const freshCollections = async (
export const freshCollections = (
lib: FreshReadLibrary, lib: FreshReadLibrary,
): Promise<Collection[]> => lib.freshCollections(); ): Promise<Collection[]> => {
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". Without // the caller can tell "no such collection" from "an empty collection".
// 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 (collectionID !== undefined) { if (!lib.getCollection(collectionID)) return undefined;
return lib.getCollection(collectionID) return lib.listFiles(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
+17 -38
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 { defaultFileName, safeExtension, withExtension } from "../filename.js"; import { safeExtension, sanitizeFileName, 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,13 +199,11 @@ const isRunning = (pid: number): boolean => {
}; };
// Delete the temp files a killed process left in `dir`: the writer's // Delete the temp files a killed process left in `dir`: the writer's
// `.quak-<pid>-<random>.tmp`, the backup copy's // `.quak-<pid>-<random>.tmp` and the backup copy's
// `.quak-backup-<name>-<pid>-<random>.tmp`, and the image and video of a live // `.quak-backup-<name>-<pid>-<random>.tmp`. Only files whose process is no
// photo downloaded to replace a stored one, `.quak-<pid>-<random>.<ext>` (see // longer running are removed, so another process writing into the same
// `replaceOriginal`). Only files whose process is no longer running are // directory keeps its own. A reused process ID can only keep a leftover a while
// removed, so another process writing into the same directory keeps its own. A // longer, never remove a live one.
// reused process ID can only keep a leftover a while longer, never remove a
// live one.
export const removeLeftoverTempFiles = (dir: string): void => { export const removeLeftoverTempFiles = (dir: string): void => {
let names: string[]; let names: string[];
try { try {
@@ -214,9 +212,7 @@ export const removeLeftoverTempFiles = (dir: string): void => {
return; return;
} }
for (const name of names) { for (const name of names) {
const match = /^\.quak-(?:.*-)?(\d+)-[0-9a-z]*\.[0-9A-Za-z]+$/.exec( const match = /^\.quak-(?:.*-)?(\d+)-[0-9a-z]*\.tmp$/.exec(name);
name,
);
if (match && !isRunning(Number(match[1]))) { if (match && !isRunning(Number(match[1]))) {
rmSync(join(dir, name), { force: true }); rmSync(join(dir, name), { force: true });
} }
@@ -365,14 +361,10 @@ 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 // replaced by its own entry's, and the two must differ ignoring case. When the
// `defaultName` is set, `destination` is the name `defaultFileName` made from // file records a hash, `<imageHash>:<videoHash>` must match it, each over that
// the title rather than a path the caller gave, and each part is named as // part's own bytes. Only then are the image, then the video, renamed into
// `defaultFileName` names it with its own extension, so that the name is cut // place; on any failure neither is stored.
// 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
@@ -392,7 +384,6 @@ 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);
@@ -491,12 +482,8 @@ 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 partPath = (ext: string): string => const path = withExtension(destination, image.ext);
defaultName const videoPath = withExtension(destination, video.ext);
? 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();
@@ -540,9 +527,6 @@ 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>>,
@@ -551,7 +535,6 @@ 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();
@@ -564,7 +547,6 @@ const fetchAndDecrypt = async (
key, key,
onProgress, onProgress,
original, original,
defaultName,
); );
} }
const bytesWritten = await decryptToTemp( const bytesWritten = await decryptToTemp(
@@ -596,11 +578,10 @@ 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. Without it, the file is // `outPath` is the caller's and is used as is; the title is the server's
// written in the current directory under the name `defaultFileName` makes // and is sanitized so it can only name a file in the current directory.
// from the title, which is the server's.
const resolvedPath = const resolvedPath =
outPath ?? defaultFileName(file.metadata.title, file.id); outPath ?? sanitizeFileName(file.metadata.title, `file-${file.id}`);
const header = fromBase64(file.file.decryptionHeader); const header = fromBase64(file.file.decryptionHeader);
return fetchAndDecrypt( return fetchAndDecrypt(
api, api,
@@ -610,7 +591,6 @@ export const downloadFile = async (
resolvedPath, resolvedPath,
onProgress, onProgress,
file, file,
outPath === undefined,
); );
}; };
@@ -620,10 +600,9 @@ 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 ??
defaultFileName(file.metadata.title, file.id, { prefix: "thumb_" }); `thumb_${sanitizeFileName(file.metadata.title, `file-${file.id}`)}`;
const header = fromBase64(file.thumbnail.decryptionHeader); const header = fromBase64(file.thumbnail.decryptionHeader);
return fetchAndDecrypt( return fetchAndDecrypt(
api, api,
+4 -56
View File
@@ -5,9 +5,7 @@
// used on disk goes through here, so it can only ever name one file inside the // used on disk goes through here, so it can only ever name one file inside the
// directory the caller chose: never a path, never `..`, never hidden, never a // directory the caller chose: never a path, never `..`, never hidden, never a
// Windows device name. Such a name can still be too long for a file name, and // Windows device name. Such a name can still be too long for a file name, and
// `shortenToBytes` or `shortenKeepingExtension` cuts one short. // `shortenToBytes` 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.
@@ -32,11 +30,11 @@ 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, it holds // The extension of `title` (".jpg"), or ".bin" when it has none or it holds
// anything but letters and digits, or it is more than 16 of them long. // anything but letters and digits.
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]{1,16}$/.test(ext) ? ext : ".bin"; return /^\.[A-Za-z0-9]+$/.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").
@@ -59,53 +57,3 @@ export const shortenToBytes = (name: string, maxBytes: number): string => {
} }
return name.slice(0, end); 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)}`;
};
+2 -116
View File
@@ -13,8 +13,6 @@
// originals, the thumbnail pool for thumbnails. The pool limits concurrency, // originals, the thumbnail pool for thumbnails. The pool limits concurrency,
// orders on-demand work ahead of background, and dedups by key so a fileID // orders on-demand work ahead of background, and dedups by key so a fileID
// requested twice while the first is still in flight downloads once. // requested twice while the first is still in flight downloads once.
// `fetchOriginal` passes no key, so it is not shared with a fetch of the same
// file already in flight.
// //
// Integrity. The reused streaming decrypt is the enforced guarantee: every // Integrity. The reused streaming decrypt is the enforced guarantee: every
// chunk is authenticated and the writer renames the file into place only once // chunk is authenticated and the writer renames the file into place only once
@@ -29,10 +27,9 @@
// It also names each original's save path under the download directory // It also names each original's save path under the download directory
// (`savePath`), where `Photo.download()` and `lib.backup()` put it. A copy in // (`savePath`), where `Photo.download()` and `lib.backup()` put it. A copy in
// the cache does not count as saved there, but is copied there rather than // the cache does not count as saved there, but is copied there rather than
// fetched again. An original stored there whose bytes do not match its content // fetched again.
// hash is replaced only by a download that does (`replaceOriginal`).
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs"; import { existsSync, readFileSync, statSync } from "node:fs";
import { import {
chmod, chmod,
copyFile, copyFile,
@@ -47,12 +44,6 @@ import {
import { basename, dirname, extname, join } from "node:path"; import { basename, dirname, extname, join } from "node:path";
import type { ApiClient } from "../api/client.js"; import type { ApiClient } from "../api/client.js";
import {
chunkHashFinal,
chunkHashInit,
chunkHashUpdate,
init,
} from "../crypto/index.js";
import { import {
downloadFile, downloadFile,
downloadThumbnail, downloadThumbnail,
@@ -358,26 +349,6 @@ export const storedAtSavePath = (
return storedOriginal(dirname(path), basename(path, extname(path)), file); return storedOriginal(dirname(path), basename(path, extname(path)), file);
}; };
// The content hash of the original stored at `stored`, computed as the download
// check computes it: over each file's bytes, read in chunks, and for a live
// photo `<imageHash>:<videoHash>`.
export const storedHash = async (stored: {
path: string;
videoPath?: string;
}): Promise<string> => {
await init();
const hashFile = async (path: string): Promise<string> => {
const state = chunkHashInit();
for await (const chunk of createReadStream(path)) {
chunkHashUpdate(state, chunk as Buffer);
}
return chunkHashFinal(state);
};
const hash = await hashFile(stored.path);
if (stored.videoPath === undefined) return hash;
return `${hash}:${await hashFile(stored.videoPath)}`;
};
// Copy bytes into `dest` via a temp file in the same directory plus rename, so // Copy bytes into `dest` via a temp file in the same directory plus rename, so
// `dest` appears only once it is whole ("present means complete"). As in the // `dest` appears only once it is whole ("present means complete"). As in the
// download writer, the temp file is fsynced before the rename and the directory // download writer, the temp file is fsynced before the rename and the directory
@@ -434,62 +405,6 @@ export const placeOriginal = async (
return { path, videoPath }; return { path, videoPath };
}; };
// Replace the original of `file` stored at its save path under `root` with a
// new copy, keeping the stored one until the copy is complete and its hash,
// computed as `storedHash` computes it, is the content hash the file's metadata
// records. `get` is given a temporary file beside the save path and writes the
// copy there, a live photo as its image and its video beside that file, each
// with its own extension. Only a copy that matches is renamed over the stored
// one, and a live photo's image and video are both hashed before either is
// renamed. The image is renamed first: should the run stop, or the video's
// rename fail, before the video is renamed, the new image is left beside the
// old video, and a later backup with `verify` replaces the video if the video
// was the part that did not match. When `get` fails or the copy does not match,
// the temporary files are removed and the stored original stays as it was.
export const replaceOriginal = async (
root: string,
file: EnteFile,
get: (dest: string) => Promise<{ path: string; videoPath?: string }>,
): Promise<{ path: string; videoPath?: string }> => {
const dest = savePath(root, file);
const dir = dirname(dest);
// Named as the download writer names its temporary files; a live photo's
// image and video get this name with their own extensions. The next
// backup removes any that a killed run leaves (`removeLeftoverTempFiles`).
const tmp = join(
dir,
`.quak-${process.pid}-${Math.random().toString(36).slice(2)}.tmp`,
);
let got: { path: string; videoPath?: string } | undefined;
try {
got = await get(tmp);
if ((await storedHash(got)) !== file.metadata.hash) {
throw new Error(
"the downloaded original does not match its content hash either",
);
}
if (got.videoPath === undefined) {
await rename(got.path, dest);
await fsyncPath(dir);
return { path: dest };
}
const path = withExtension(dest, extname(got.path));
const videoPath = withExtension(dest, extname(got.videoPath));
await rename(got.path, path);
await rename(got.videoPath, videoPath);
const name = basename(dest, extname(dest));
await writeLivePhotoJSON(dir, name, { path, videoPath });
return { path, videoPath };
} finally {
// Whatever was not renamed into place. A failure to remove it must
// not replace the error that explains what went wrong.
for (const path of [tmp, got?.path, got?.videoPath]) {
if (path === undefined) continue;
await rm(path, { force: true }).catch(() => undefined);
}
}
};
export class ContentCache implements PhotoContent, ThumbnailsAPI { export class ContentCache implements PhotoContent, ThumbnailsAPI {
private readonly pools: RequestPools; private readonly pools: RequestPools;
private readonly source: ContentSource; private readonly source: ContentSource;
@@ -628,35 +543,6 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
}; };
} }
// Fetch an original through the content pool to `destination`, a live
// photo as its image and its video beside it, and return where it landed.
// Unlike `backupOriginal`, it fetches even when the cache or the save path
// already holds the original, and it records nothing: what it writes is
// the caller's. It is not shared with a fetch of the same file already
// under way, which writes somewhere else.
async fetchOriginal(
fileID: number,
destination: string,
): Promise<{ path: string; videoPath?: string }> {
const file = this.getFile(fileID);
if (!file) throw new Error(`content cache: unknown file ${fileID}`);
return this.pools.content.run(
async () => {
const fetched = await this.fetchInto(
file,
destination,
"original",
undefined,
);
for (const path of [fetched.path, fetched.videoPath]) {
if (path !== undefined) await chmod(path, FILE_MODE);
}
return fetched;
},
{ priority: "on-demand" },
);
}
async ensure(args: EnsureOptions): Promise<EnsureResult[]> { async ensure(args: EnsureOptions): Promise<EnsureResult[]> {
return this.ensureThumbnails(args); return this.ensureThumbnails(args);
} }
+25 -72
View File
@@ -18,16 +18,6 @@
// 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
@@ -171,21 +161,18 @@ export interface LibraryClient {
// exactly one of "done" or "failed"; "failed" carries the error message and // exactly one of "done" or "failed"; "failed" carries the error message and
// an ML "done" reports how many payloads it stored and, in `mlDataErrors`, // an ML "done" reports how many payloads it stored and, in `mlDataErrors`,
// each file whose ML data did not decrypt, gunzip or parse, with the reason. // 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 // The precache fills ("precacheThumbnails"/"precacheOriginals", #48) fire
// fetched: `fetched` is how many collections it has fetched the files of so // "started"/"done" around each sweep that has work, "done" reporting the count
// far, `total` how many it will. The precache fills // newly cached.
// ("precacheThumbnails"/"precacheOriginals", #48) fire "started"/"done" around
// each sweep that has work, "done" reporting the count newly cached.
export interface RefreshEvent { export interface RefreshEvent {
operation: operation:
| "refresh" | "refresh"
| "fetchMLData" | "fetchMLData"
| "precacheThumbnails" | "precacheThumbnails"
| "precacheOriginals"; | "precacheOriginals";
status: "started" | "progress" | "done" | "failed"; status: "started" | "done" | "failed";
error?: string; error?: string;
fetched?: number; fetched?: number;
total?: number;
mlDataErrors?: { fileID: number; error: string }[]; mlDataErrors?: { fileID: number; error: string }[];
} }
@@ -205,10 +192,6 @@ 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
@@ -250,10 +233,10 @@ export interface LibraryStatus {
lastMLFetchAt?: number; lastMLFetchAt?: number;
// The most recent ML fetch pass's error, set only while it failed. // The most recent ML fetch pass's error, set only while it failed.
lastMLError?: string; lastMLError?: string;
// Each file whose ML data the last ML fetch pass that succeeded got but // Each file whose ML data the last ML fetch pass to finish got but could
// could not decrypt, gunzip or parse, with the reason. A pass still // not decrypt, gunzip or parse, with the reason. A pass still running
// running leaves it as it is. A pass that fails leaves it as it was. Such // leaves it as it is. Such ML data is not cached, so the next pass asks
// ML data is not cached, so the next pass asks for it again. // for it again.
mlDataErrors: { fileID: number; error: string }[]; mlDataErrors: { fileID: number; error: string }[];
// ML payloads stored on disk and CLIP embeddings in the index; undefined // ML payloads stored on disk and CLIP embeddings in the index; undefined
// when ML fetching is disabled. // when ML fetching is disabled.
@@ -322,11 +305,11 @@ export class Library {
private lastError?: string; private lastError?: string;
private lastMLFetchAt?: number; private lastMLFetchAt?: number;
private lastMLError?: string; private lastMLError?: string;
// For each file whose ML data the last ML fetch pass that succeeded got // For each file whose ML data the last ML fetch pass to finish got but
// but could not decrypt, gunzip or parse, why not, by fileID. A pass // could not decrypt, gunzip or parse, why not, by fileID. A pass gathers
// gathers its own and puts them here when it ends without failing, so // its own and puts them here when it ends, so `status()` and `backup()`
// `status()` and `backup()` never see the list of a pass that is still // never see the list of a pass that is still running. Kept in RAM only,
// running or that failed. Kept in RAM only, never in the cache. // never in the cache.
private mlDataErrors = new Map<number, unknown>(); 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
@@ -399,8 +382,7 @@ 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. With `refreshOnOpen: false` there is no first // not block opening.
// 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 =
@@ -503,10 +485,7 @@ export class Library {
// refresh. // refresh.
precache?.start(); precache?.start();
if (opts.refreshOnOpen === false) { if (store.loadedFromDisk) {
// 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());
@@ -610,21 +589,6 @@ 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
@@ -662,8 +626,6 @@ export class Library {
undecryptable: () => this.store.listUndecryptable(), undecryptable: () => this.store.listUndecryptable(),
original: (fileID, destination) => original: (fileID, destination) =>
cache!.backupOriginal(fileID, destination), cache!.backupOriginal(fileID, destination),
fetchOriginal: (fileID, destination) =>
cache!.fetchOriginal(fileID, destination),
thumbnail: (fileID) => cache!.thumbnail(fileID), thumbnail: (fileID) => cache!.thumbnail(fileID),
contentConcurrency: this.pools.content.concurrency, contentConcurrency: this.pools.content.concurrency,
fetchMLData: () => this.fetchMLDataNow(), fetchMLData: () => this.fetchMLDataNow(),
@@ -778,28 +740,18 @@ 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. Each collection // collections enumerate from the beginning of time.
// 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 changedCollections) { for (const collection of page.collections) {
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
@@ -908,8 +860,8 @@ export class Library {
// advanced), through the metadata pool, and update the CLIP index. For a // advanced), through the metadata pool, and update the CLIP index. For a
// file whose ML data does not decrypt, gunzip or parse, it gathers the // file whose ML data does not decrypt, gunzip or parse, it gathers the
// reason instead, and puts the reasons it gathered in `mlDataErrors` when // reason instead, and puts the reasons it gathered in `mlDataErrors` when
// it ends without failing. A failure leaves `mlDataErrors` as it was and // it ends. A failure is reported through `status()` and `onProgress`,
// is reported through `status()` and `onProgress`, then thrown. // 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
@@ -919,9 +871,9 @@ export class Library {
const files = this.uniqueFiles(); const files = this.uniqueFiles();
const needed = mldata.neededFor(files); const needed = mldata.neededFor(files);
// This pass's reasons, put in `this.mlDataErrors` when it ends without // This pass's reasons, put in `this.mlDataErrors` when it ends. A
// failing. A file whose ML data did not decode in the pass before is // file whose ML data did not decode in the pass before is still among
// still among `needed`, so this pass gives its reason anew. // `needed`, so this pass gives its reason anew.
const mlDataErrors = new Map<number, unknown>(); const mlDataErrors = new Map<number, unknown>();
if (needed.length === 0) { if (needed.length === 0) {
this.mlDataErrors = mlDataErrors; this.mlDataErrors = mlDataErrors;
@@ -962,6 +914,7 @@ export class Library {
mlDataErrors: this.describeMLDataErrors(), mlDataErrors: this.describeMLDataErrors(),
}); });
} catch (err) { } catch (err) {
this.mlDataErrors = mlDataErrors;
const error = err instanceof Error ? err.message : String(err); const error = err instanceof Error ? err.message : String(err);
this.lastMLError = error; this.lastMLError = error;
this.emit({ operation: "fetchMLData", status: "failed", error }); this.emit({ operation: "fetchMLData", status: "failed", error });
+2 -14
View File
@@ -5,11 +5,7 @@ import type { Client } from "./client.js";
import { describeError } from "./errors.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 { import { sanitizeFileName } from "./filename.js";
MAX_NAME_BYTES,
sanitizeFileName,
shortenToBytes,
} from "./filename.js";
import { import {
fetchMLDataBatch, fetchMLDataBatch,
MLDATA_BATCH_SIZE, MLDATA_BATCH_SIZE,
@@ -131,15 +127,7 @@ export const runMetadataBackup = async (
const col = lib.getCollection(album.collectionID); const col = lib.getCollection(album.collectionID);
if (!col) continue; if (!col) continue;
// The album's ID keeps the folders apart, so the name after it can be const dirName = `${col.id}-${sanitizeFileName(col.name, "unnamed")}`;
// 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 });
+1 -78
View File
@@ -9,7 +9,7 @@
* data this file encrypts with libsodium, as Ente's clients encrypt it. So * data this file encrypts with libsodium, as Ente's clients encrypt it. So
* quak's own code fetches each original, decrypts it, checks it against the * quak's own code fetches each original, decrypts it, checks it against the
* hash its uploader recorded and unpacks a live photo, and quak's own retries * hash its uploader recorded and unpacks a live photo, and quak's own retries
* repeat a failed request. Three tests run `lib.backup()` instead, as a program * repeat a failed request. Two tests run `lib.backup()` instead, as a program
* using quak as a library does. * using quak as a library does.
* *
* The account, user 42, has three albums: * The account, user 42, has three albums:
@@ -927,83 +927,6 @@ describe("quak backup", () => {
} }
}); });
// A program using quak as a library backs the account up, and another ML
// data fetch, as one the library's refresh starts, fails while the backup
// writes the JSON files. The backup's own fetch got beach.jpg's ML data
// under a key that is not the file's; the fetch that fails leaves that
// reason in place, so beach.jpg's JSON and `failures.json` still give it.
it("keeps the reason in beach.jpg's JSON and in failures.json when an ML data fetch fails while lib.backup() writes the JSON files", async () => {
const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const mlData = encryptStreamBlob(
gzipSync(JSON.stringify(ML_DATA)),
otherKey,
);
let mlDataFetchFails = false;
const fetch: typeof globalThis.fetch = async (input, init) => {
if (new URL(String(input)).pathname !== "/files/data/fetch") {
return server(input, init);
}
if (mlDataFetchFails) throw new Error("the connection was reset");
const data = [
{
fileID: BEACH.id,
encryptedData: toBase64(mlData.ciphertext),
decryptionHeader: toBase64(mlData.header),
},
];
return new Response(JSON.stringify({ data }), {
headers: { "content-type": "application/json" },
});
};
const dir = mkdtempSync(join(root, "backup-"));
const lib = await Library.open({
client: loadSession(join(sessionDir, "session.json"), {
fetch,
retry: { ...UNATTENDED_RETRY_OPTIONS, sleep: async () => {} },
})!,
cacheDirectory: `${dir}-cache`,
downloadDirectory: dir,
refreshIntervalSeconds: Infinity,
precacheThumbnails: false,
precacheOriginals: false,
});
try {
// The backup reads a file's cached ML data just before it writes
// that file's JSON. The first time, a refresh starts an ML data
// fetch whose request fails, and the backup goes on once that
// fetch has failed.
const forFile = lib.mldata.forFile.bind(lib.mldata);
vi.spyOn(lib.mldata, "forFile").mockImplementationOnce(
async (args) => {
mlDataFetchFails = true;
await lib.fresh();
await vi.waitFor(
() => expect(lib.status().lastMLError).toBeDefined(),
{ timeout: 2000, interval: 5 },
);
return forFile(args);
},
);
const result = await lib.backup();
expect(result.failed).toBe(1);
const reason = "secretstream chunk authentication failed";
const beachJSON = readJSON(join(dir, DAY, "2024-03-01.100.json"));
expect(beachJSON).not.toHaveProperty("mlData");
expect(beachJSON.mlDataError).toBe(reason);
const failures = readJSON(join(dir, "failures.json"));
expect(Object.keys(failures.files)).toEqual(["100"]);
expect(failures.files["100"]).toMatchObject({
fileID: 100,
title: "beach.jpg",
error: `ML data: ${reason}`,
});
} finally {
await lib.close();
}
});
// Something changed beach.jpg on disk after it was backed up. A run with // Something changed beach.jpg on disk after it was backed up. A run with
// --verify hashes every stored original, finds that one no longer matches // --verify hashes every stored original, finds that one no longer matches
// the hash Ente recorded, says so, and downloads it again; it put back the // the hash Ente recorded, says so, and downloads it again; it put back the
+1 -1
View File
@@ -548,7 +548,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), "1", {})).toBe(0); expect(await filesCommand(context(ente), { collection: "1" })).toBe(0);
expect(stdout.text).toBe("10\timage\tphoto-10.jpg\n"); expect(stdout.text).toBe("10\timage\tphoto-10.jpg\n");
}); });
}); });
+31 -93
View File
@@ -191,12 +191,9 @@ class HostileClient extends MockClient {
} }
// A content source that writes byte buffers of the expected length and can be // A content source that writes byte buffers of the expected length and can be
// told to fail one fileID's original, as a server error or as a 404, or to // told to fail one fileID's original, to exercise per-file resilience.
// write other bytes for it, to exercise per-file resilience.
interface StubSource extends ContentSource { interface StubSource extends ContentSource {
failID?: number; failID?: number;
notFoundID?: number;
otherBytesID?: number;
failThumbID?: number; failThumbID?: number;
originalCalls: number; originalCalls: number;
} }
@@ -207,10 +204,8 @@ const stubSource = (): StubSource => {
original: async ({ file: f, destination }) => { original: async ({ file: f, destination }) => {
s.originalCalls++; s.originalCalls++;
if (s.failID === f.id) throw new Error("HTTP 500 from server"); if (s.failID === f.id) throw new Error("HTTP 500 from server");
if (s.notFoundID === f.id) throw new Error("HTTP 404 from server");
const size = SIZE_BY_ID[f.id] ?? 10; const size = SIZE_BY_ID[f.id] ?? 10;
const fill = s.otherBytesID === f.id ? 2 : 0; writeFileSync(destination, Buffer.alloc(size));
writeFileSync(destination, Buffer.alloc(size, fill));
return { bytesWritten: size }; return { bytesWritten: size };
}, },
thumbnail: async ({ file: f, destination }) => { thumbnail: async ({ file: f, destination }) => {
@@ -701,26 +696,17 @@ describe("lib.backup", () => {
// A child that has already exited: its process ID is not running. // A child that has already exited: its process ID is not running.
const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid; const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid;
const leftover = `.quak-backup-2026-03-01.100.jpg-${exitedPID}-abc123.tmp`; const leftover = `.quak-backup-2026-03-01.100.jpg-${exitedPID}-abc123.tmp`;
// A live photo's image and video downloaded to replace a stored one,
// before they were renamed into place.
const replacement = [
`.quak-${exitedPID}-abc789.heic`,
`.quak-${exitedPID}-abc789.mov`,
];
// This test's own process stands in for a backup running at the same // This test's own process stands in for a backup running at the same
// time. // time.
const inProgress = `.quak-backup-2026-03-01.101.jpg-${process.pid}-def456.tmp`; const inProgress = `.quak-backup-2026-03-01.101.jpg-${process.pid}-def456.tmp`;
for (const name of [leftover, ...replacement, inProgress]) { writeFileSync(join(day, leftover), "partial");
writeFileSync(join(day, name), "partial"); writeFileSync(join(day, inProgress), "partial");
}
const lib = await openLibrary(stubSource()); const lib = await openLibrary(stubSource());
await lib.backup({ downloadDirectory: outDir }); await lib.backup({ downloadDirectory: outDir });
const names = readdirSync(day); const names = readdirSync(day);
expect(names).not.toContain(leftover); expect(names).not.toContain(leftover);
expect(names).not.toContain(replacement[0]);
expect(names).not.toContain(replacement[1]);
expect(names).toContain(inProgress); expect(names).toContain(inProgress);
lib.close(); lib.close();
}); });
@@ -1309,8 +1295,7 @@ describe("image metadata in each file's JSON", () => {
}); });
// With `verify`, each original already stored is hashed, and one that does not // With `verify`, each original already stored is hashed, and one that does not
// match the content hash its metadata records is downloaded again, and replaced // match the content hash its metadata records is downloaded again.
// only by a download that matches.
describe("backup with verify", () => { describe("backup with verify", () => {
// MockClient's files, each recording the hash of what `stubSource` writes // MockClient's files, each recording the hash of what `stubSource` writes
// for it, except diagram.png (200), which records none. // for it, except diagram.png (200), which records none.
@@ -1353,12 +1338,6 @@ describe("backup with verify", () => {
const good = Buffer.alloc(SIZE_BY_ID[100]!); const good = Buffer.alloc(SIZE_BY_ID[100]!);
const corrupt = Buffer.alloc(SIZE_BY_ID[100]!, 1); const corrupt = Buffer.alloc(SIZE_BY_ID[100]!, 1);
// The temporary files left in the date folder of the backup in `outDir`.
const tempFiles = (outDir: string): string[] =>
readdirSync(join(outDir, DAY)).filter((name) =>
name.startsWith(".quak-"),
);
it("leaves an original that matches its hash, and one with no hash, as they are", async () => { it("leaves an original that matches its hash, and one with no hash, as they are", async () => {
const { lib, source, outDir } = await backedUp(); const { lib, source, outDir } = await backedUp();
const calls = source.originalCalls; const calls = source.originalCalls;
@@ -1403,16 +1382,13 @@ describe("backup with verify", () => {
expect(log.filter((msg) => msg.startsWith("MISMATCH"))).toEqual([ expect(log.filter((msg) => msg.startsWith("MISMATCH"))).toEqual([
"MISMATCH original beach.jpg (100): its bytes do not match its content hash", "MISMATCH original beach.jpg (100): its bytes do not match its content hash",
]); ]);
expect(tempFiles(outDir)).toEqual([]);
await lib.close(); await lib.close();
}); });
// A copy whose hash does not match is still mostly the photo, so the it("records a failed download in failures.json, with the original removed", async () => {
// backup keeps it until it holds a better one.
it("keeps an original that does not match its hash when its download answers 404", async () => {
const { lib, source, outDir } = await backedUp(); const { lib, source, outDir } = await backedUp();
writeFileSync(saved(outDir, "100.jpg"), corrupt); writeFileSync(saved(outDir, "100.jpg"), corrupt);
source.notFoundID = 100; source.failID = 100;
const result = await lib.backup({ const result = await lib.backup({
downloadDirectory: outDir, downloadDirectory: outDir,
@@ -1425,18 +1401,18 @@ describe("backup with verify", () => {
failed: 1, failed: 1,
}); });
expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]); expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]);
expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(corrupt); expect(existsSync(saved(outDir, "100.jpg"))).toBe(false);
expect(
readlinkSync(join(outDir, "collections", "Vacation", "beach.jpg")),
).toBe(linkTo("100.jpg"));
expect(tempFiles(outDir)).toEqual([]);
await lib.close(); await lib.close();
}); });
it("keeps an original that does not match its hash when its download does not match either", async () => { it("records in failures.json an original the cache puts back with the same bad bytes", async () => {
const { lib, source, outDir } = await backedUp(); const lib = await openLibrary(stubSource(), new HashedClient());
writeFileSync(saved(outDir, "100.jpg"), corrupt); const outDir = join(root, "backup");
source.otherBytesID = 100; // The cache holds a bad copy, and a backup copies an original the
// cache holds to its save path.
const cached = await lib.photos.byID({ fileID: 100 })!.original();
writeFileSync(cached.path, corrupt);
await lib.backup({ downloadDirectory: outDir });
const result = await lib.backup({ const result = await lib.backup({
downloadDirectory: outDir, downloadDirectory: outDir,
@@ -1449,39 +1425,10 @@ describe("backup with verify", () => {
failed: 1, failed: 1,
}); });
expect(result.errors.map((e) => e.error)).toEqual([ expect(result.errors.map((e) => e.error)).toEqual([
"the downloaded original does not match its content hash either", "the original put back does not match its content hash either",
]); ]);
expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]); expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]);
expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(corrupt); expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(corrupt);
expect(tempFiles(outDir)).toEqual([]);
await lib.close();
});
// The cache can hold the same bad bytes, as a copy from it is not checked
// as a download is, so the replacement is always downloaded.
it("downloads the replacement even when the cache holds the original", async () => {
const source = stubSource();
const lib = await openLibrary(source, new HashedClient());
const outDir = join(root, "backup");
// The cache holds a bad copy, and a backup copies an original the
// cache holds to its save path.
const cached = await lib.photos.byID({ fileID: 100 })!.original();
writeFileSync(cached.path, corrupt);
await lib.backup({ downloadDirectory: outDir });
const calls = source.originalCalls;
const result = await lib.backup({
downloadDirectory: outDir,
verify: true,
});
expect(result).toMatchObject({
downloaded: 1,
mismatched: 1,
failed: 0,
});
expect(source.originalCalls).toBe(calls + 1);
expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(good);
await lib.close(); await lib.close();
}); });
@@ -1565,9 +1512,6 @@ describe("backup album folders", () => {
writeFileSync(path, `original ${fileID}`); writeFileSync(path, `original ${fileID}`);
return { path }; return { path };
}, },
fetchOriginal: async () => {
throw new Error("no verify in this stand-in");
},
thumbnail: async () => { thumbnail: async () => {
throw new Error("no thumbnails in this stand-in"); throw new Error("no thumbnails in this stand-in");
}, },
@@ -1795,11 +1739,12 @@ describe("backup album folders", () => {
expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 1 }); expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 1 });
}); });
// The extensions of these titles leave no room for the ID and the "a" // The extensions of these titles leave no room for the ID, so each is cut
// before them, so each is cut short to 250 bytes, which leaves one, for // short and the name before the ID keeps only the bytes that cut leaves
// the "a". File 1's extension is 303 bytes and file 2's 271. Neither // over. File 1's extension is 303 bytes and is cut to 251, which leaves
// extension is only letters and digits, so both originals are saved with // none. File 2's is 271 bytes and is cut to 250, since "€" takes three
// `.bin`, a name that fits. // bytes, which leaves one, for the "a". Neither extension is only letters
// and digits, so both originals are saved with `.bin`, a name that fits.
it("cuts the extension of a title short when it leaves no room for the ID", async () => { it("cuts the extension of a title short when it leaves no room for the ID", async () => {
const outDir = join(root, "backup"); const outDir = join(root, "backup");
const lib = libraryOf([ const lib = libraryOf([
@@ -1814,7 +1759,7 @@ describe("backup album folders", () => {
const result = await runBackup(lib, { downloadDirectory: outDir }); const result = await runBackup(lib, { downloadDirectory: outDir });
const link = `a (1).${"L".repeat(249)}`; const link = ` (1).${"L".repeat(250)}`;
const link2 = `a (2).${"€".repeat(83)}`; const link2 = `a (2).${"€".repeat(83)}`;
expect(Buffer.byteLength(link)).toBe(255); expect(Buffer.byteLength(link)).toBe(255);
expect(Buffer.byteLength(link2)).toBe(255); expect(Buffer.byteLength(link2)).toBe(255);
@@ -1829,9 +1774,9 @@ describe("backup album folders", () => {
// Files 1 and 3 share a title, so each gets its ID before the // 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 // 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 // room for the ID. Were the ID put at the end of file 2's title cut
// file 2's title cut short, its link would have file 1's name, and the // short, its link would have file 1's name, and the link made second
// link made second would replace the first. // would replace the first.
it("keeps names unique when a title's extension leaves no room for the ID", async () => { it("keeps names unique when a title's extension leaves no room for the ID", async () => {
const outDir = join(root, "backup"); const outDir = join(root, "backup");
const shared = `x.${"Q".repeat(245)} (2)`; const shared = `x.${"Q".repeat(245)} (2)`;
@@ -1851,8 +1796,8 @@ describe("backup album folders", () => {
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
expect(tree(outDir)).toEqual([ expect(tree(outDir)).toEqual([
"Trip/", "Trip/",
`Trip/ (2).${"Q".repeat(250)} -> ${linkTo("2.bin")}`,
`Trip/x (1).${"Q".repeat(245)} (2) -> ${linkTo("1.bin")}`, `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/x (3).${"Q".repeat(245)} (2) -> ${linkTo("3.bin")}`,
"Trip.json", "Trip.json",
]); ]);
@@ -2301,12 +2246,11 @@ describe("backup of live photos", () => {
Buffer.from(IMAGE), Buffer.from(IMAGE),
); );
expect(readFileSync(video)).toEqual(Buffer.from(VIDEO)); expect(readFileSync(video)).toEqual(Buffer.from(VIDEO));
expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored);
expect(tree(outDir)).toEqual(linked); expect(tree(outDir)).toEqual(linked);
await lib.close(); await lib.close();
}); });
it("keeps both files of a live photo that does not match its hash when downloading it again fails", async () => { it("removes both files of a live photo that does not match its hash when downloading it again fails", async () => {
const { file: live, body } = await asLivePhoto( const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"), file(500, 10, "IMG_0500.HEIC"),
); );
@@ -2324,14 +2268,8 @@ describe("backup of live photos", () => {
expect(result).toMatchObject({ mismatched: 1, failed: 1 }); expect(result).toMatchObject({ mismatched: 1, failed: 1 });
expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]); expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]);
expect(readFileSync(saved(outDir, "500.heic"))).toEqual( expect(existsSync(saved(outDir, "500.heic"))).toBe(false);
Buffer.from(IMAGE), expect(existsSync(saved(outDir, "500.mov"))).toBe(false);
);
expect(readFileSync(saved(outDir, "500.mov"), "utf-8")).toBe(
"another few seconds of video",
);
expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored);
expect(tree(outDir)).toEqual(linked);
await lib.close(); await lib.close();
}); });
+6 -24
View File
@@ -1,7 +1,6 @@
/** /**
* 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.
* 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,
@@ -27,19 +26,14 @@ 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 and return what it wrote on stderr, which is // Run `quak <args>` to completion. The "Not logged in" message and the exit
// kept off the terminal. The exit is swallowed. // are swallowed.
const quak = async (...args: string[]): Promise<string> => { const quak = async (...args: string[]): Promise<void> => {
let written = ""; vi.spyOn(process.stderr, "write").mockImplementation(() => true);
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(() => {
@@ -75,15 +69,3 @@ describe("bin/quak.ts session loading", () => {
expect(loaded).toEqual([undefined]); expect(loaded).toEqual([undefined]);
}); });
}); });
// 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/);
});
});
+45 -396
View File
@@ -21,7 +21,7 @@ import {
utimesSync, utimesSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { basename, join } from "node:path"; import { 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";
@@ -53,21 +53,11 @@ import {
} from "../../src/cli-commands.js"; } from "../../src/cli-commands.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 { import type { Client, ClientSnapshot, LoginOptions } from "../../src/client.js";
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,
@@ -121,31 +111,25 @@ 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. `beachInWork` adds // the collection's updationTime as the server does, and `refreshError` makes
// beach.jpg (100) to Work as well, so that one file is in both albums. // listing collections fail with that message.
// `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;
let files = FILES; const files = opts.withNewFile
if (opts.withNewFile) { ? {
files = { ...FILES,
...files, 1: [...FILES[1], { ...file(102, 1, "new.jpg"), updationTime: 2 }],
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");
@@ -187,15 +171,6 @@ 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 = "";
@@ -263,7 +238,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, "1", {})).toBe(3); expect(await filesCommand(ctx, { collection: "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);
@@ -510,14 +485,17 @@ 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(), "1", {})).toBe(0); expect(await filesCommand(context(), { collection: "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(), "2", { json: true }); const code = await filesCommand(context(), {
collection: "2",
json: true,
});
expect(code).toBe(0); expect(code).toBe(0);
expect(JSON.parse(stdout.text)).toEqual([ expect(JSON.parse(stdout.text)).toEqual([
{ {
@@ -531,42 +509,8 @@ describe("files", () => {
}); });
it("exits 1 for an unknown collection", async () => { it("exits 1 for an unknown collection", async () => {
expect(await filesCommand(context(), "9", {})).toBe(1); expect(await filesCommand(context(), { collection: "9" })).toBe(1);
expect(stderr.text).toBe( expect(stderr.text).toBe("Collection 9 not found\n");
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("");
}); });
}); });
@@ -575,134 +519,39 @@ 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(FETCHED_BOTH_ALBUMS + `7 bytes -> ${out}\n`); expect(stderr.text).toBe(`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(FETCHED_BOTH_ALBUMS + `3 bytes -> ${out}\n`); expect(stderr.text).toBe(`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(FETCHED_BOTH_ALBUMS + "File 999 not found\n"); expect(stderr.text).toBe("File 999 not found\n");
expect(existsSync(out)).toBe(false); expect(existsSync(out)).toBe(false);
}); });
it("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(FETCHED_BOTH_ALBUMS + "File 999 not found\n"); expect(stderr.text).toBe("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 ( const livePhotoClient = async (image = IMAGE): Promise<Client> => {
image = IMAGE,
title = "IMG_0300.HEIC",
): Promise<Client> => {
const { file: live, body } = await asLivePhoto( const { file: live, body } = await asLivePhoto(
file(300, 1, title), file(300, 1, "IMG_0300.HEIC"),
livePhotoZip({ "image.heic": image, "video.mov": VIDEO }), livePhotoZip({ "image.heic": image, "video.mov": VIDEO }),
livePhotoHash(image, VIDEO), livePhotoHash(image, VIDEO),
); );
@@ -744,50 +593,11 @@ describe("a live photo", () => {
Buffer.from(VIDEO), Buffer.from(VIDEO),
); );
expect(stderr.text).toBe( expect(stderr.text).toBe(
FETCHED_ONE_ALBUM + `${IMAGE.length} bytes -> IMG_0300.heic\n` +
`${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");
@@ -799,8 +609,7 @@ 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(
FETCHED_ONE_ALBUM + `${IMAGE.length} bytes -> ${out}\n${VIDEO.length} bytes -> ${video}\n`,
`${IMAGE.length} bytes -> ${out}\n${VIDEO.length} bytes -> ${video}\n`,
); );
}); });
@@ -814,8 +623,7 @@ 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(
FETCHED_ONE_ALBUM + `File 300 is a live photo, and its video would also be written to ${out}\n`,
`File 300 is a live photo, and its video would also be written to ${out}\n`,
); );
}); });
@@ -1295,12 +1103,11 @@ describe("backup-metadata --exif", () => {
}); });
}); });
// Each test first runs `files` so the cache holds the account as it was, then // Each test first runs `collections` so the cache holds the account as it was,
// changes the server under it. (`collections` would not do: it reads the // then changes the server under it.
// 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 filesCommand(context(), "1", {})).toBe(0); expect(await collectionsCommand(context(), {})).toBe(0);
stdout.text = ""; stdout.text = "";
stderr.text = ""; stderr.text = "";
}); });
@@ -1359,10 +1166,10 @@ describe("backup-metadata and the thumbnail helpers refresh first", () => {
}); });
}); });
// `files`, `get`, `get-thumb` and `helper fix-missing-thumbnails --file` take // `files --collection`, `get`, `get-thumb` and `helper fix-missing-thumbnails
// an ID only when it is written in decimal digits and is a safe integer. // --file` take an ID only when it is written in decimal digits and is a safe
// `Number()` alone reads `0x10` as 16, `1e3` as 1000 and an empty string as 0, // integer. `Number()` alone reads `0x10` as 16, `1e3` as 1000 and an empty
// so the command would act on a file the user never named. // string as 0, so the command would act on a file the user never named.
// Anything else is refused with one line naming it, and exit 1, before any // Anything else is refused with one line naming it, and exit 1, before any
// request. // request.
// //
@@ -1416,18 +1223,21 @@ 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, "1", {})).toBe(1); expect(await filesCommand(ctx, { collection: "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)("files refuses %j before any request", async (arg) => { it.each(NOT_IDS)(
const ctx = emptyAccount(); "files refuses --collection %j before any request",
expect(await filesCommand(ctx, arg, {})).toBe(1); async (arg) => {
expect(stderr.text).toBe(`Invalid collection ID: ${arg}\n`); const ctx = emptyAccount();
expect(stdout.text).toBe(""); expect(await filesCommand(ctx, { collection: arg })).toBe(1);
expect(requests).toHaveLength(0); expect(stderr.text).toBe(`Invalid collection ID: ${arg}\n`);
}); expect(stdout.text).toBe("");
expect(requests).toHaveLength(0);
},
);
it("get looks up a decimal file ID", async () => { it("get looks up a decimal file ID", async () => {
expect(await getCommand(emptyAccount(), "100", {})).toBe(1); expect(await getCommand(emptyAccount(), "100", {})).toBe(1);
@@ -1482,164 +1292,3 @@ 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);
});
// With `--json` the refresh's progress is not printed, as `backup` and
// the thumbnail helpers print none of theirs.
it("files --json prints no progress", 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,
},
]);
expect(stderr.text).toBe("");
});
});
+11 -48
View File
@@ -3,13 +3,10 @@
* 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. `files`, `get` and `get-thumb` * current server state, not the local cache, so each helper forces a
* force a `Library.fresh()` round-trip before they read; the stand-in library * `Library.fresh()` round-trip before it reads. The stand-in library below
* below serves nothing until `fresh()` has been awaited, so a helper that read * serves nothing until `fresh()` has been awaited, so a helper that read
* without refreshing would come back empty and fail here. `collections` asks * without refreshing would come back empty and fail here.
* 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
@@ -63,13 +60,10 @@ 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. `freshCollections()` // serves them in the enumeration order it was given. `photos.byID` returns a
// answers the collection list in that order without a refresh. `photos.byID` // stand-in `Photo` carrying just the fileID the helper passes through.
// 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(
@@ -83,18 +77,12 @@ class FakeLibrary implements FreshReadLibrary {
return {}; return {};
} }
async freshCollections(): Promise<Collection[]> { listCollections(): Collection[] {
this.freshCollectionsCalls++; return this.refreshed ? this.collections : [];
return this.collections;
} }
getCollection(id: number): Collection | undefined { getCollection(id: number): Collection | undefined {
if (!this.refreshed) return undefined; return this.listCollections().find((c) => c.id === id);
return this.collections.find((c) => c.id === id);
}
listCollections(): Collection[] {
return this.refreshed ? this.collections : [];
} }
listFiles(collectionID: number): EnteFile[] { listFiles(collectionID: number): EnteFile[] {
@@ -118,7 +106,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 reads the collection list alone, in enumeration order", async () => { it("freshCollections refreshes first, then lists 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)],
@@ -126,8 +114,7 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
); );
const rows = await freshCollections(lib); const rows = await freshCollections(lib);
expect(lib.freshCollectionsCalls).toBe(1); expect(lib.freshCalls).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.
@@ -179,30 +166,6 @@ 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);
-89
View File
@@ -658,26 +658,6 @@ 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(
@@ -771,28 +751,6 @@ 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]);
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1923,53 +1881,6 @@ 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);
-108
View File
@@ -15,10 +15,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
defaultFileName,
safeExtension, safeExtension,
sanitizeFileName, sanitizeFileName,
shortenKeepingExtension,
shortenToBytes, shortenToBytes,
} from "../../src/filename.js"; } from "../../src/filename.js";
@@ -96,12 +94,6 @@ 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", () => { describe("shortenToBytes", () => {
@@ -126,103 +118,3 @@ describe("shortenToBytes", () => {
); );
}); });
}); });
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,9 +14,6 @@
* 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
@@ -273,70 +270,3 @@ 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,9 +26,6 @@
* 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.
@@ -584,107 +581,6 @@ 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")