From e6a9e929c2f7872dd5840488e52ba1897f99c1cf Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 29 Sep 2026 05:22:57 +0200 Subject: [PATCH 01/39] Bring README and TODO.md in line with next after the milestone merge (closes #132) README.md and TODO.md were read end to end against the code after the milestone merge, and every sentence the code contradicted was corrected. The corrections cover the login and key-derivation steps (the TOTP step, email OTP, the SRP library), retries and download staging, which CLI commands take which options, which of each file's metadata the backup keeps, how default and fresh reads behave, the cache options, and what the tests cover. TODO.md's next step now says no implementation work is open and the cache design waits on sneak. Docs only. Left as written: the development workflow's `main` base, a process question. Merged under the docs-only rule after four reviews; the fix for the fourth review's one finding was not re-reviewed. Model: opus-5-5 --- README.md | 236 ++++++++++++++++++++++++++++++------------------------ TODO.md | 32 +++++++- 2 files changed, 162 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index 37832c9..2a9f82a 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ and downloads individual images while decrypting them on the way to disk. quak also includes a resilient backup command that downloads every file in the account into a deduplicated local directory tree, skipping files that already exist on disk and continuing past individual download failures instead of -crashing. It decrypts and persists all three metadata layers (basic, private -magic, public magic) per file, including camera info, GPS coordinates, captions, -and any face/keyword labels the Ente clients have added. A helper subcommand can +crashing. For each file it persists the basic metadata fields quak keeps (title, +file type, creation and modification time, latitude, longitude, content hash), +and the private and public magic metadata in full. A helper subcommand can detect and regenerate missing thumbnails, encrypting and uploading them back to the server. @@ -51,7 +51,8 @@ const client = await Client.login({ // Open a cache-backed library. On an empty cache this awaits one server // refresh; on an existing cache it returns immediately and refreshes in the -// background every `refreshIntervalSeconds` (default 3). +// background. Later refreshes start `refreshIntervalSeconds` (default 3) after +// the previous one ends. const lib = await Library.open({ client }); // Default reads answer synchronously from the local cache — no network. @@ -62,7 +63,7 @@ for (const album of lib.albums.list()) { } } -// Fresh reads await a server round-trip and answer with current state. +// Fresh reads await a server round-trip, joining one already running. const { albums } = await lib.fresh(); console.log(`${albums.list().length} albums as of now`); @@ -84,7 +85,7 @@ enumeration/download calls) is exported too and documented under Design below. This repository adheres to the [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) standard: normalized scripts in `script/` are the entrypoints for the -development workflow, and the Makefile targets are thin shims that call them. +development workflow, and most Makefile targets are thin shims that call them. The scripts are POSIX sh (not bash) so they run in minimal containers such as alpine. We provide: @@ -122,9 +123,9 @@ alpine. We provide: Linting and testing are phases of the `Dockerfile`. The `lint` phase copies the repo into a digest-pinned node image and runs eslint and `prettier --check .`; the `test` phase does the same with the suite. `script/lint` and `script/test` -each build one phase with `docker build --no-cache --target `. There is -no host lint or test path: docker is required, and that also works where the -docker daemon is remote and bind mounts are impossible. +each build one phase with `docker build --no-cache --target `. Neither +script runs the tools on the host: docker is required, and that also works where +the docker daemon is remote and bind mounts are impossible. The last stage of the `Dockerfile` compiles the package, and it copies a file from each phase, so it cannot be built unless lint and the tests pass. That is @@ -209,7 +210,7 @@ the CLI is for humans. ``` quak/ src/ - crypto/ libsodium primitives (boxes, secretstreams, KDF, SRP) + crypto/ libsodium primitives (boxes, secretstreams, KDF, hash) api/ HTTP client (ApiClient class) auth/ login flow (SRP + email OTP + TOTP), key unwrap model/ decrypted Collection, File, Metadata types + decrypt fns @@ -219,7 +220,7 @@ quak/ and search, request pools backup.ts resilient full-account backup with dedup metadata-backup.ts - backup-metadata: all decrypted metadata as JSON + backup-metadata: the metadata quak keeps, as JSON mldata-fetch.ts fetch + decrypt per-file ML data filename.ts safe file names from server metadata errors.ts error types shared across layers @@ -250,7 +251,9 @@ the repository root rather than `src/`, because `bin/` is compiled too and ### Cryptography All cryptography is done by `libsodium-wrappers-sumo` (the "sumo" build is -required for `crypto_pwhash` / Argon2id). No hand-rolled crypto. +required for `crypto_pwhash` / Argon2id), except the SRP handshake, which uses +`fast-srp-hap`, and the MD5 checksum sent with a thumbnail upload, which uses +Node's built-in `node:crypto`. No hand-rolled crypto. The key hierarchy, derived during login, is: @@ -258,19 +261,23 @@ The key hierarchy, derived during login, is: 2. Argon2id (`crypto_pwhash`) over the password and a server-issued `kekSalt`, with server-issued `memLimit` and `opsLimit`, produces a 32-byte Key Encryption Key (KEK). -3. SRP login: a 16-byte SRP login subkey is derived from the KEK using - `crypto_kdf_derive_from_key` (BLAKE2b) with subkey id 1 and context - `loginctx`. That 16-byte value is the SRP password. -4. After SRP completes (or after email-OTP fallback), the server returns a blob - of "key attributes" plus an encrypted auth token. +3. SRP login: `crypto_kdf_derive_from_key` (BLAKE2b) derives a 32-byte subkey + from the KEK with subkey id 1 and context `loginctx`. Its first 16 bytes are + the SRP password. +4. When SRP completes, the server returns a blob of "key attributes" plus an + encrypted auth token, or first asks for a second factor. quak answers a TOTP + request with the code (`POST /users/two-factor/verify`), after which the + server returns them, and cannot answer a passkey request. When the account + has email MFA on (`isEmailMFAEnabled`), an email OTP replaces SRP and the + server returns them after it. 5. `crypto_secretbox_open_easy` over the encrypted master key with the KEK yields the 32-byte master key. 6. `crypto_secretbox_open_easy` over the encrypted secret key with the master key yields the user's X25519 private key. The matching public key is delivered in cleartext. 7. `crypto_box_seal_open` over the encrypted token with the user's keypair - yields the URL-safe base64 auth token used in `X-Auth-Token` for all - subsequent calls. + yields the auth token's bytes. Encoded as URL-safe base64 with padding, they + are the `X-Auth-Token` value for all subsequent calls. Per-collection keys are decrypted with `crypto_secretbox_open_easy` using the master key (for owned collections). Per-file keys are decrypted with @@ -306,7 +313,8 @@ Endpoints used: - `POST /users/srp/create-session`: begin SRP handshake. - `POST /users/srp/verify-session`: complete SRP, receive 2FA challenge or the encrypted token plus key attributes. -- `POST /users/ott` and `POST /users/verify-email`: email OTP fallback path. +- `POST /users/ott` and `POST /users/verify-email`: email OTP, used instead of + SRP when the SRP attributes have `isEmailMFAEnabled` set. - `POST /users/two-factor/verify`: TOTP second factor. - `POST /users/logout`: end the calling token's session (`quak logout`). - `GET /collections/v2?sinceTime=`: list collections changed since @@ -328,8 +336,9 @@ request is repeated only when repeating it could produce a different answer: - Every other 4xx: not retried. A 404 in particular is an answer, and `listMissingThumbnails` depends on getting it promptly and once. - Transport failures — a `fetch` rejection, `ECONNRESET`, `ETIMEDOUT`, a DNS or - TLS failure — and deadline aborts: retried. The errno is looked for in the - error's `cause` chain, because that is where Node's `fetch` puts it. + TLS failure — and deadline aborts: retried. Node's `fetch` rejects with a + plain `TypeError`, so every `TypeError` is retried. The errno is looked for in + the error's `cause` chain, because that is where Node's `fetch` puts it. - A truncated download: retried. - Anything else, including a secretstream authentication failure that is not truncation: not retried. The default answer is no. For a backup tool, retrying @@ -395,10 +404,13 @@ because a socket reset after the response headers have arrived surfaces in the download layer rather than in `ApiClient`, and that is the common failure for multi-megabyte photos over a CDN. The secretstream pull state is not resumable and these endpoints have no Range support, so a retry starts the file over. The -atomic write stays outside the retry, so a download that needed three attempts -still performs exactly one write and one rename. `runBackup` and -`runMetadataBackup` are unchanged: the retry sits below them, and a file that -fails after exhausting it is still logged, counted, and stepped over. +atomic write is part of the retried unit: each attempt writes its own temporary +files, one for most files and two for a live photo (its image and its video), +and removes them if it fails. Only the attempt that completes renames anything +into place. The retry sits below `runBackup` and `runMetadataBackup`. +`runBackup` logs, counts and steps over a file that still fails after its +retries; `runMetadataBackup`, which downloads only with `--exif`, records the +error in that file's JSON as `imageMetadataError` and goes on. One imprecision is deliberate and worth knowing about. When a body ends part-way through a secretstream chunk, Poly1305 fails and carries no framing signal, so a @@ -422,10 +434,11 @@ base64-encoded keys) that the consumer can write to disk, a database, or whatever else fits their use case. `Client.fromJSON(snapshot)` restores a working client from that snapshot without re-authenticating; it checks every field and each key's length first, and throws an error naming the bad field. -`client.logout()` clears the token and zeroes the key buffers in place; every -later call on that client throws. It does not contact the server, so the token -stays valid there and in any saved snapshot; `await client.logoutOnServer()` -first ends the session on the server (`POST /users/logout`). +`client.logout()` clears the token and zeroes the key buffers in place; after +it, every other method on that client throws. It does not contact the server, so +the token stays valid there and in any saved snapshot; +`await client.logoutOnServer()` first ends the session on the server +(`POST /users/logout`). The CLI stores the snapshot at the platform-appropriate data directory via `env-paths`: `~/Library/Application Support/quak/session.json` on macOS, @@ -433,41 +446,43 @@ The CLI stores the snapshot at the platform-appropriate data directory via `0600`. The key material is stored in cleartext in the JSON; treat this file as you would treat the password itself. A missing file is reported as "not logged in"; a file that exists but is corrupt is reported as such, naming the bad -field. Both exit with status 1. +field. Both exit with status 1, except that `quak logout` with no file says +there is no session and exits 0. `quak logout` ends the session on the server, so the token in `session.json` stops working even in a copy of the file, and then deletes the file. If the server call fails (or the file is corrupt), the file is still deleted, the command says the server session could not be ended, and it exits with status 1. -It does not delete the cache: it prints the account's cache directory and says -it still holds decrypted data (file keys in `metadata.json`, cached originals -and thumbnails), for the user to delete if they want it gone. +It does not delete the cache. When it knows the cache directory, from +`--cache-dir` or from a session file it could read, it prints it and says it +still holds decrypted data (file keys in `metadata.json`, cached originals and +thumbnails), for the user to delete if they want it gone. ### CLI surface ``` -quak [--cache-dir ] global: local metadata/content cache location -quak login interactive or QUAK_EMAIL/QUAK_PASSWORD -quak whoami print logged-in account as JSON -quak logout end the session, delete it -quak collections [--json] list all collections -quak files --collection [--json] list files in a collection -quak get [--out path] [--collection] download and decrypt a file -quak get-thumb [--out] [--collection] download and decrypt a thumbnail -quak backup [--json] full incremental backup -quak backup-metadata [--exif] dump all decrypted metadata as JSON -quak helper list-missing-thumbnails [--json] find files with missing thumbnails -quak helper fix-missing-thumbnails [--file ids] generate + upload missing thumbnails +quak [--cache-dir ] global: local metadata/content cache location +quak login interactive or QUAK_EMAIL/QUAK_PASSWORD +quak whoami print logged-in account as JSON +quak logout end the session, delete it +quak collections [--json] list all collections +quak files --collection [--json] list files in a collection +quak get [--out path] [--collection] download and decrypt a file +quak get-thumb [--out] [--collection] download and decrypt a thumbnail +quak backup [--json] full incremental backup +quak backup-metadata [--exif] dump the metadata quak keeps as JSON +quak helper list-missing-thumbnails [--json] find files with missing thumbnails +quak helper fix-missing-thumbnails [--file ids] [--json] generate + upload missing thumbnails ``` -Every command runs on the same cache-backed library. The read commands — -`collections`, `files`, `get`, `get-thumb`, `backup-metadata`, -`helper list-missing-thumbnails` and `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 held. If that round-trip fails, the command -prints the error on one line and exits 1. `--cache-dir` overrides where the -cache lives; without it each account gets its own directory under the per-user -cache path. +Every command except `login`, `whoami` and `logout` runs on the cache-backed +library. The read commands — `collections`, `files`, `get`, `get-thumb`, +`backup-metadata`, `helper list-missing-thumbnails` and +`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 +held. If that round-trip fails, the command prints the error on one line and +exits 1. `--cache-dir` overrides where the cache lives; without it each account +gets its own directory under the per-user cache path. `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 @@ -475,18 +490,20 @@ its image and its video, each named after the title with its own extension, as Ente's clients name them (`IMG_0001.heic` and `IMG_0001.mov`). With `--out PATH`, the image is written to `PATH` and the video beside it, with `PATH`'s name and the video's extension; a `PATH` with the video's extension is -refused. `backup-metadata --exif` (alias `--all`) additionally downloads each -file to extract full EXIF/IPTC/XMP metadata. The listing and backup commands -support `--json` for machine-readable output. +refused. `backup-metadata --exif` (alias `--all`) additionally fetches each +file's original through the cache and records its XMP metadata and, for a JPEG, +its EXIF metadata and dimensions. `collections`, `files`, `backup`, +`helper list-missing-thumbnails` and `helper fix-missing-thumbnails` take +`--json` for machine-readable output. `backup-metadata` fetches ML data in requests of up to 200 files. When a request -still fails after its retries, the error is logged, each of its files is written -with the reason in an `mlDataError` field instead of `mlData`, and the dump goes -on. The exit code is non-zero if any ML data request failed. +fails, the error is logged, each of its files is written with the reason in an +`mlDataError` field instead of `mlData`, and the dump goes on. The exit code is +non-zero if any ML data request failed. -`helper fix-missing-thumbnails` regenerates thumbnails for baseline JPEG images -only, because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG -image (PNG, HEIC) or a video is reported as `skipped` (unsupported format), kept +`helper fix-missing-thumbnails` regenerates thumbnails for JPEG images only, +because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG image +(PNG, HEIC) or a video is reported as `skipped` (unsupported format), kept distinct from a `failed` repair, and does not affect the exit code; a genuine failure still exits non-zero. The server accepts a new thumbnail only from the file's owner and only when it is no larger than the thumbnail size it records @@ -505,7 +522,9 @@ the smallest does not. originals/ . actual file content (one per unique file, two for a live photo: see below) - .json all decrypted metadata for that file + .json the file's basic metadata fields quak + keeps, and its private and public magic + metadata .livephoto.json which of a live photo's two files is which collections/ / @@ -550,7 +569,9 @@ the cache. An original the cache already held is copied from there instead. On subsequent runs, existing originals are skipped. If a download fails, the error is logged and the backup continues with the next file. The exit code is non-zero if any files failed. `quak backup` opens its library with the thumbnail and -originals precache off, so it fetches only what the backup stores. +originals precache off, so the only file content it fetches is the originals the +backup stores. The library's ML data fetch still runs and fills the cache's +`mldata/`. 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 @@ -567,8 +588,8 @@ temporary file's permissions, not those of the file it replaced. ## TODO -- [x] Retry policy: no retry on 4xx, exponential backoff on 5xx and network - errors +- [x] Retry policy: no retry on 4xx (except `408` and `429`), exponential + backoff on 5xx and network errors - [x] Update the API reference section below to match the current implementation - [x] `make docker` green - [x] Store live photos in a form a photo viewer can open @@ -591,7 +612,8 @@ Future (desktop client, separate repo): The library's primary surface is the cache-backed `Library`; the lower-level `Client` sits underneath it and is covered by the Design sections above. The test suite is the canonical, executable documentation — `test/library/` and -`test/client/usage.test.ts` walk every operation, and `yarn test` verifies them. +`test/client/usage.test.ts` walk most operations, `test/cli/backup.test.ts` +walks `lib.backup()`, and `yarn test` verifies them. ### Opening a library @@ -604,21 +626,21 @@ background, so an unreachable server does not block opening. `LibraryOptions`: -| Option | Default | Meaning | -| ------------------------ | --------------------------- | --------------------------------------------------------------------- | -| `client` | required | the account client (a `Client`, or any `LibraryClient`) | -| `cacheDirectory` | `/quak/` | where `metadata.json` and the content cache live | -| `downloadDirectory` | none | backup destination; an original already stored there counts as cached | -| `refreshIntervalSeconds` | `3` | background refresh cadence | -| `precacheThumbnails` | `true` | prefetch every thumbnail, newest first | -| `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals | -| `precacheOriginalsDays` | `7` | length in days of that latest window | -| `cacheOriginalsMaxBytes` | 100 GiB | hard ceiling on the originals cache | -| `freeBelowBytes` | 50 GiB | free space to protect on the volume; the effective limit adapts down | -| `isOriginalPinned` | none | extra predicate for originals that must never be evicted | -| `pools` | fresh `RequestPools` | the bounded request pools (sets concurrency) | -| `onProgress` | none | refresh/ML/precache progress callback (`RefreshEvent`) | -| `contentSource` | the client's own | override the byte source (mainly for tests) | +| Option | Default | Meaning | +| ------------------------ | -------------------------------------------------- | --------------------------------------------------------------------- | +| `client` | required | the account client (a `Client`, or any `LibraryClient`) | +| `cacheDirectory` | `quak/` under the per-user cache directory | where `metadata.json` and the content cache live | +| `downloadDirectory` | none | backup destination; an original already stored there counts as cached | +| `refreshIntervalSeconds` | `3` | background refresh cadence | +| `precacheThumbnails` | `true` | prefetch every thumbnail, newest first | +| `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals | +| `precacheOriginalsDays` | `7` | length in days of that latest window | +| `cacheOriginalsMaxBytes` | 100 GiB | size limit on the originals cache; pinned originals can exceed it | +| `freeBelowBytes` | 50 GiB | free space to protect on the volume; the effective limit adapts down | +| `isOriginalPinned` | none | extra predicate for originals that must never be evicted | +| `pools` | fresh `RequestPools` | the bounded request pools (sets concurrency) | +| `onProgress` | none | refresh/ML/precache progress callback (`RefreshEvent`) | +| `contentSource` | the client's own | override the byte source (mainly for tests) | Concurrency is set through `pools`: construct `new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })` @@ -635,17 +657,21 @@ can then be removed. ### Default reads vs. fresh reads Default reads — `lib.albums`, `lib.photos`, `lib.timeline` — answer -synchronously from the last refreshed copy held in RAM and never touch the -network. The background timer refreshes that copy every -`refreshIntervalSeconds`, so a default read is immediate but may be up to one -interval stale. +synchronously from the copy held in RAM and never touch the network. A refresh +changes that copy only once all its server requests have succeeded, and the +background timer starts the next refresh `refreshIntervalSeconds` after the +previous one ends. So a default read is immediate, but only as current as the +last refresh whose requests all succeeded; right after opening an existing +cache, it is the copy on disk. -`await lib.fresh()` forces a refresh, waits for it to complete and persist, and -returns the same `{ albums, photos, timeline }` namespaces — now guaranteed to -reflect a completed server round-trip. Concurrent `fresh()` calls coalesce onto -one refresh, and a refresh that fails rejects the caller (default reads stay -silent and keep serving the last good copy). The CLI's read commands use fresh -reads (issue https://git.eeqj.de/sneak/quak/issues/75). +`await lib.fresh()` waits for a refresh to complete and persist, and returns the +same `{ albums, photos, timeline }` namespaces, which then reflect a completed +server round-trip. When a refresh is already running, background or not, +`fresh()` waits for that one, so its answer can come from requests made before +the call; only when none is running does it start one. A refresh that fails +rejects the caller (default reads stay silent and keep serving the last good +copy). The CLI's read commands use fresh reads (issue +https://git.eeqj.de/sneak/quak/issues/75). ### Read surface @@ -662,8 +688,8 @@ reads (issue https://git.eeqj.de/sneak/quak/issues/75). `includeArchived`; hidden photos are always excluded. An `Album` exposes its record fields and `album.photos.list()` → `Photo[]` -(newest first). A `Photo` exposes its record fields, `photo.record()` → -`PhotoRecord`, and two content methods: +(newest first). A `Photo` exposes its record fields other than `thumbnailPath` +and `originalPath`, `photo.record()` → `PhotoRecord`, and two content methods: - `await photo.original(opts?)` → `{ path, bytes, videoPath? }` — the full-resolution file. For a live photo, `path` and `bytes` are its image's and @@ -712,16 +738,16 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange` `SimilarResult[]` (`{ fileID, score }`, cosine similarity, most similar first, default limit 20). quak bundles no text encoder, so `searchByEmbedding` takes a query vector the caller produced elsewhere. -- `await lib.backup(opts?)` → `BackupResult`. It refreshes, fetches every - in-scope original not already in the backup (and, with `includeThumbnails`, - thumbnails) through the content cache, and rebuilds the on-disk backup tree - with a durable failure ledger. A fetched original is written straight into the - backup's `originals/` and not into the cache, which then counts it as present; - one the cache already held is copied from there. `BackupOptions`: - `downloadDirectory` (falls back to the one `open()` was given), - `includeOriginals` (default `true`), `includeThumbnails` (default `false`), - `onlyAlbumNames`, and `onProgress`. See Backup layout above for the tree it - writes. +- `await lib.backup(opts?)` → `BackupResult`. It waits for a refresh as + `fresh()` does, fetches every in-scope original not already in the backup + (and, with `includeThumbnails`, thumbnails) through the content cache, and + rebuilds the on-disk backup tree with a durable failure ledger. A fetched + original is written straight into the backup's `originals/` and not into the + cache, which then counts it as present; one the cache already held is copied + from there. `BackupOptions`: `downloadDirectory` (falls back to the one + `open()` was given), `includeOriginals` (default `true`), `includeThumbnails` + (default `false`), `onlyAlbumNames`, and `onProgress`. See Backup layout above + for the tree it writes. ### Request pools diff --git a/TODO.md b/TODO.md index e6c2c8a..0fafba7 100644 --- a/TODO.md +++ b/TODO.md @@ -14,13 +14,43 @@ pre-1.0 # Next Step -None: every issue still open is done on `next` and waits for it to reach `main`. +None: no implementation work is open. The cache design, +https://git.eeqj.de/sneak/quak/issues/36, waits on sneak's review. Tagging and releases are decided by sneak alone, and happen only when he declares one. # Completed Steps +- 2026-09-29: Brought the README and this file in line with `next` after the + milestone merge (issue 132). The Next Step says no implementation work is open + and the cache design (issue 36) waits on sneak's review. README corrections: + the Getting Started comments say when the library refreshes; most, not all, + Makefile targets call a script; `script/lint` and `script/test` have no host + path, though `yarn test` does; the SRP handshake uses `fast-srp-hap`, outside + `crypto/`, and a thumbnail upload's MD5 uses `node:crypto`; the SRP password + is the first 16 bytes of a 32-byte subkey; the key attributes and token come + after SRP, after the TOTP code SRP may ask for, or after the email OTP that + replaces SRP when the account has email MFA on, and quak cannot answer a + passkey; the auth token is sent as URL-safe base64 with padding; every + `TypeError` is retried; each download attempt writes its own temporary files, + two for a live photo, and only the attempt that completes renames them into + place; `runMetadataBackup` records a failed download in the file's JSON; a + second `client.logout()` does not throw; `quak logout` with no session exits + 0, and names the cache directory only when it knows it; `login`, `whoami` and + `logout` open no library; `--exif` records XMP and, for a JPEG, EXIF, and no + IPTC; which commands take `--json`; a failed ML data request may not have been + retried; the thumbnail fixer is not limited to baseline JPEG; `quak backup` + still fetches ML data; a backup's JSON holds the basic metadata fields quak + keeps and the private and public magic metadata, not every decrypted field, + and the README no longer lists what the magic metadata holds; the default + cache directory is the per-user one, not an XDG path on macOS; pinned + originals can exceed `cacheOriginalsMaxBytes`; which tests cover which + operations; a default read is only as current as the last refresh whose + requests all succeeded; `fresh()` and `lib.backup()` join a refresh already + running; a `Photo` has no `thumbnailPath` or `originalPath`; and `408` and + `429` are retried. + - 2026-09-28: Tested the live-photo writer's fsyncs (issue 130). A test checks that the image's and the video's temp files are fsynced before either is renamed into place, and the directory after both renames, as the `writeAtomic` From c27e2cb62936a7f5eeec91138a47a41a8ea1827d Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 29 Sep 2026 05:38:36 +0200 Subject: [PATCH 02/39] README development workflow: branch from and merge into next (closes #135) "Development workflow" and "For LLMs" said work branches off main and merges into main. They now say work branches from next, every pull request targets next, the repository manager squash-merges reviewed pull requests into next once make check is green, and only sneak merges next into main. The rest of both sections is unchanged. Docs only; merged under the docs-only rule without an adversarial review. Model: opus-5-5 --- README.md | 24 ++++++++++++++---------- TODO.md | 5 +++++ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 2a9f82a..58d33c1 100644 --- a/README.md +++ b/README.md @@ -171,16 +171,19 @@ local cache is reliable, and the UI is responsive on a five-year-old laptop. All work on quak is test-driven. No exceptions. -1. Every change starts on a feature branch off `main`. +1. Every change starts on a feature branch off `next`, and its pull request + targets `next`. 2. The first commit on the branch is the test suite for what is being added or changed. Those tests must fail at that commit; the branch is red until the implementation lands. 3. Subsequent commits add the implementation and any refactors needed to make the tests pass. -4. A feature branch can only be merged into `main` when `make check` is green. - `main` is always green. CI runs `script/cibuild`, which builds the - `Dockerfile`: its `lint` and `test` phases, then the compile, so neither a - red branch nor one that does not compile can pass CI. +4. A pull request can only be merged into `next` when `make check` is green. + Once it has passed review, the repository manager squash-merges it into + `next`. Only sneak merges `next` into `main`. `main` and `next` are always + green. CI runs `script/cibuild`, which builds the `Dockerfile`: its `lint` + and `test` phases, then the compile, so neither a red branch nor one that + does not compile can pass CI. 5. Tests are the canonical API documentation for this library. Every test file is commented thoroughly enough that a reader who has never seen quak can learn how to use it from the tests alone. Comments explain why a behavior @@ -198,7 +201,7 @@ All work on quak is test-driven. No exceptions. not the tests, and so not the full `make check`. This is deliberate so the TDD red-phase commit (failing tests, no implementation yet) can land. The `test` phase is part of the image build, which is what CI executes via - `script/cibuild`, so a red branch still cannot reach `main`. + `script/cibuild`, so a red branch still cannot reach `next`. ## Design @@ -839,14 +842,15 @@ documents: `yarn.lock`. Never `git add -A`. Never force-push to main. - **The "Development workflow" section above.** All changes go on feature - branches. Tests are written first and committed in a failing state before the - implementation. Tests are the canonical API documentation and must be - commented thoroughly. `main` is always green. + branches off `next`, and every pull request targets `next`; only sneak merges + `next` into `main`. Tests are written first and committed in a failing state + before the implementation. Tests are the canonical API documentation and must + be commented thoroughly. `main` and `next` are always green. - **Required checks before every commit:** `make lint` must pass — that is eslint plus the prettier check, and it builds the `lint` phase of the `Dockerfile`, so it needs docker. The pre-commit hook enforces exactly that. - `make check` (which also runs the tests) must pass before merging to `main`. + `make check` (which also runs the tests) must pass before merging into `next`. `make fmt-check` is available for a host-side formatting check on its own, but it is not a separate requirement: `make lint` already covers it, and running both would check formatting twice. Never invoke eslint or prettier directly; diff --git a/TODO.md b/TODO.md index 0fafba7..ba35aec 100644 --- a/TODO.md +++ b/TODO.md @@ -22,6 +22,11 @@ declares one. # Completed Steps +- 2026-09-29: The README's "Development workflow" and "For LLMs" sections now + say work branches from `next`, every pull request targets `next`, the + repository manager squash-merges reviewed pull requests into `next`, and only + sneak merges `next` into `main` (issue 135). + - 2026-09-29: Brought the README and this file in line with `next` after the milestone merge (issue 132). The Next Step says no implementation work is open and the cache design (issue 36) waits on sneak's review. README corrections: From e6825abcdb0d88e9509c741a9c4e58bfa1800968 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 29 Sep 2026 05:54:35 +0200 Subject: [PATCH 03/39] TODO.md workflow: branch from and merge into next (closes #137) The "Workflow" list said to branch from main and to merge to main or open a pull request. It now says to branch from next, open a pull request that targets next, that the repository manager squash-merges a reviewed pull request into next, and that only sneak merges next into main, as the README does. The other items are unchanged. No other text in the repo contradicted the policy. Docs only; merged under the docs-only rule without an adversarial review. Model: opus-5-5 Co-authored-by: clawbot --- TODO.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index ba35aec..5799b9a 100644 --- a/TODO.md +++ b/TODO.md @@ -1,12 +1,15 @@ # Workflow -- branch (from `main`) +- branch from `next` - do the work in Next Step - move Next Step to the top of Completed Steps - move the top item of Future Steps into Next Step - commit (`TODO.md` changes in the same commit as the work) -- merge to `main` if the branch is not protected, otherwise open a PR - push +- open a pull request that targets `next` +- once the pull request has passed review, the repository manager squash-merges + it into `next` +- only sneak merges `next` into `main` # Status @@ -22,6 +25,11 @@ declares one. # Completed Steps +- 2026-09-29: The "Workflow" list at the top of this file now says to branch + from `next` and open a pull request that targets `next`, that the repository + manager squash-merges a reviewed pull request into `next`, and that only sneak + merges `next` into `main`, as the README does (issue 137). + - 2026-09-29: The README's "Development workflow" and "For LLMs" sections now say work branches from `next`, every pull request targets `next`, the repository manager squash-merges reviewed pull requests into `next`, and only From 2b598d36226b57bede51891423c758fccff91a11 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 17:58:23 +0200 Subject: [PATCH 04/39] Photo: save path, is-local, content bytes, metadata and EXIF getters (closes #141) `Photo` gains: - `savePath` and `isLocal`: synchronous, disk only. Where `lib.backup()` writes the original under the library's `downloadDirectory`, and whether all of it is there; a copy only in the cache does not count. - `content()` and `exif()`: async, may download. `exif()` reads the common EXIF fields of a JPEG and returns `{}` for anything else. - The getters `modifiedAt` and `hash`, also on `PhotoRecord`, and `year`. For a live photo the backup has not stored yet, `savePath` carries the title's extension, and the image may be stored under a different one. The JPEG EXIF scan moved to `src/exif.ts`. The exported `PhotoContent` interface gains `savePath` and `isLocal`. Judgement call: `iso` is read only when the file stores it as a single number. Model: opus-5-5 --- README.md | 46 ++++- TODO.md | 10 ++ src/exif.ts | 145 +++++++++++++++ src/index.ts | 1 + src/library/content.ts | 30 ++++ src/library/read.ts | 67 +++++-- src/library/records.ts | 16 +- src/metadata-backup.ts | 55 +----- test/cli/metadata-exif.test.ts | 6 +- test/library/content-library.test.ts | 252 ++++++++++++++++++++++++++- test/library/read.test.ts | 23 +++ test/library/records.test.ts | 1 + 12 files changed, 566 insertions(+), 86 deletions(-) create mode 100644 src/exif.ts diff --git a/README.md b/README.md index 58d33c1..a75f15b 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,7 @@ quak/ backup.ts resilient full-account backup with dedup metadata-backup.ts backup-metadata: the metadata quak keeps, as JSON + exif.ts EXIF read from a JPEG's bytes mldata-fetch.ts fetch + decrypt per-file ML data filename.ts safe file names from server metadata errors.ts error types shared across layers @@ -692,16 +693,42 @@ https://git.eeqj.de/sneak/quak/issues/75). An `Album` exposes its record fields and `album.photos.list()` → `Photo[]` (newest first). A `Photo` exposes its record fields other than `thumbnailPath` -and `originalPath`, `photo.record()` → `PhotoRecord`, and two content methods: +and `originalPath`, and `photo.year`, the local-time year of `takenAt`, all as +synchronous getters read from RAM; `photo.record()` → `PhotoRecord`. Two more +synchronous getters look at the disk and never touch the network: + +- `photo.savePath` → `string | undefined` — where `lib.backup()` writes the + original, `originals/.` under the `downloadDirectory` the library + was opened with, whether or not it is there yet: for a live photo already + stored, its image. For a live photo not yet stored, it carries the title's + extension, and the backup may store the image under a different one, found + inside the live photo. `undefined` when the library has no `downloadDirectory` + or no content source. +- `photo.isLocal` → `boolean` — whether the whole original is at `savePath`. + +Four async methods may download: - `await photo.original(opts?)` → `{ path, bytes, videoPath? }` — the full-resolution file. For a live photo, `path` and `bytes` are its image's and `videoPath` is its video. - `await photo.thumbnail(opts?)` → `{ path, bytes }`. +- `await photo.content(opts?)` → `Uint8Array` — the original's bytes, read + through `original()`; for a live photo, its image's. +- `await photo.exif(opts?)` → `PhotoExif` — `make`, `model`, `lensModel`, + `dateTimeOriginal`, `offsetTimeOriginal`, `exposureTime`, `fNumber`, `iso`, + `focalLength`, `orientation`, `gpsLatitude`, `gpsLongitude` and `gpsAltitude`, + each absent when the file lacks it. GPS values are signed decimal degrees and + metres. `dateTimeOriginal` is the camera's clock reading held in the `Date`'s + UTC fields; `offsetTimeOriginal`, when present, is that clock's offset from + UTC. Only a JPEG's EXIF is read: any other original gives `{}`, and a video + gives `{}` without being downloaded. -Both serve from the on-disk content cache when the bytes are present and -otherwise fetch through the pools; `opts.onProgress` reports per-file progress. -They throw when the library was opened without a content source. +They serve from the on-disk content cache when the bytes are present and +otherwise fetch through the pools; `original()`, `content()` and `exif()` also +serve an original the backup has already stored. `opts.onProgress` reports +per-file progress. They throw when the library was opened without a content +source. An original that `content()` or `exif()` downloads lands in the cache, +which does not make `isLocal` true; only `lib.backup()` does. Lower-level accessors that return decrypted model objects (which hold key material) are also available: `listCollections()`, `getCollection(id)`, @@ -713,10 +740,12 @@ material) are also available: `listCollections()`, `getCollection(id)`, The GUI-facing records hold no key material and no binary, so they survive `structuredClone`/JSON across the Electron IPC boundary: -- `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` (milliseconds), - `fileType`, optional `caption` / `width` / `height` / `latitude` / - `longitude`, `isArchived`, `isHidden`, and `thumbnailPath` / `originalPath` - once the bytes are cached (for a live photo, `originalPath` is its image). +- `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` and `modifiedAt` + (milliseconds), `fileType`, optional `caption` / `width` / `height` / + `latitude` / `longitude`, optional `hash` (the content hash recorded at + upload; very old files have none), `isArchived`, `isHidden`, and + `thumbnailPath` / `originalPath` once the bytes are cached (for a live photo, + `originalPath` is its image). - `AlbumRecord`: `collectionID`, `name`, `type`, `isShared`, `updationTime`, and `fileIDs` (newest first). - `LibrarySnapshot`: `{ albums, photos, takenAt }`. @@ -811,6 +840,7 @@ from a very old client, is stored unchecked. - `src/library/records.ts`: `PhotoRecord`, `AlbumRecord`, `LibrarySnapshot`, `LibraryChange` - `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult` +- `src/exif.ts`: `PhotoExif` - `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool` - `src/backup.ts`: `BackupOptions`, `BackupResult`, `BackupError` - `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot` diff --git a/TODO.md b/TODO.md index 5799b9a..4c188b5 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,16 @@ declares one. # Completed Steps +- 2026-10-01: A `Photo` has `savePath`, `isLocal`, `content()`, `exif()`, + `modifiedAt`, `hash` and `year` (issue 141). `savePath` is where + `lib.backup()` writes the original under the library's download directory; for + a live photo not yet stored, it carries the title's extension, and the backup + may store the image under a different one. `isLocal` says whether the whole + original is there. Both look only at the disk. `content()` returns the + original's bytes and `exif()` the common EXIF fields of a JPEG; both may + download the original, and `exif()` downloads no video. `PhotoRecord` gains + `modifiedAt` and `hash`, and the JPEG EXIF scan moved to `src/exif.ts`. + - 2026-09-29: The "Workflow" list at the top of this file now says to branch from `next` and open a pull request that targets `next`, that the repository manager squash-merges a reviewed pull request into `next`, and that only sneak diff --git a/src/exif.ts b/src/exif.ts new file mode 100644 index 0000000..778cbfa --- /dev/null +++ b/src/exif.ts @@ -0,0 +1,145 @@ +// EXIF in a JPEG's bytes. `backup-metadata --exif` records the whole EXIF block +// it finds; `Photo.exif()` returns the common fields picked from it here. + +import exifReader from "exif-reader"; + +// Find the raw EXIF APP1 segment in JPEG bytes. Returns `exif` (the segment +// data, starting at the "Exif\0\0" header) when there is one, nothing when the +// bytes are not a JPEG or carry no EXIF, and `error` when the segment layout is +// malformed. Each segment length is checked against the bytes that remain and +// each step moves forward by at least 4 bytes, so the scan ends on any input. +export const extractExifFromJpeg = ( + buf: Uint8Array, +): { exif?: Buffer; error?: string } => { + if (buf[0] !== 0xff || buf[1] !== 0xd8) return {}; + let offset = 2; + while (offset < buf.length) { + if (offset + 2 > buf.length) + return { error: `truncated segment marker at byte ${offset}` }; + if (buf[offset] !== 0xff) + return { error: `no segment marker at byte ${offset}` }; + const marker = buf[offset + 1]!; + if (marker === 0xda) return {}; // start of scan, no more markers + if (offset + 4 > buf.length) + return { error: `truncated segment length at byte ${offset}` }; + const len = (buf[offset + 2]! << 8) | buf[offset + 3]!; + // The length counts its own two bytes, so anything under 2 is invalid. + if (len < 2) + return { + error: `segment length ${len} at byte ${offset} is too small`, + }; + if (offset + 2 + len > buf.length) + return { + error: `segment length ${len} at byte ${offset} runs past the end of the file`, + }; + if (marker === 0xe1) { + // APP1 — check for "Exif\0\0" header. A length under 8 cannot hold + // the six-byte header, so the segment is not EXIF; below 6 the + // bytes compared would also lie past the segment. + if ( + len >= 8 && + buf[offset + 4] === 0x45 && + buf[offset + 5] === 0x78 && + buf[offset + 6] === 0x69 && + buf[offset + 7] === 0x66 + ) { + return { + exif: Buffer.from( + buf.buffer, + buf.byteOffset + offset + 4, + len - 2, + ), + }; + } + } + offset += 2 + len; + } + return { error: "file ends before the image data" }; +}; + +// The common EXIF fields of an original. Each is absent when the file lacks it. +export interface PhotoExif { + make?: string; + model?: string; + lensModel?: string; + // When the photo was taken, by the camera's clock. EXIF writes this as text + // with no time zone, and exif-reader reads that text as if it were UTC: the + // Date's UTC fields are the clock reading, which is the moment it was taken + // only when the clock was set to UTC. + dateTimeOriginal?: Date; + // The camera clock's offset from UTC, such as "+02:00". + offsetTimeOriginal?: string; + // Seconds. + exposureTime?: number; + fNumber?: number; + iso?: number; + // Millimetres. + focalLength?: number; + // The EXIF orientation code, 1 to 8. + orientation?: number; + // Decimal degrees, negative south of the equator and west of Greenwich. + gpsLatitude?: number; + gpsLongitude?: number; + // Metres, negative below sea level. + gpsAltitude?: number; +} + +const asString = (v: unknown): string | undefined => + typeof v === "string" && v.length > 0 ? v : undefined; + +const asNumber = (v: unknown): number | undefined => + typeof v === "number" && Number.isFinite(v) ? v : undefined; + +const asDate = (v: unknown): Date | undefined => + v instanceof Date && !Number.isNaN(v.getTime()) ? v : undefined; + +// EXIF writes a GPS coordinate as three numbers: degrees, minutes and seconds. +// This is them as decimal degrees, negated when `negative`. +const asDegrees = (v: unknown, negative: boolean): number | undefined => { + if (!Array.isArray(v) || v.length !== 3) return undefined; + const [d, m, s] = v.map(asNumber); + if (d === undefined || m === undefined || s === undefined) return undefined; + const degrees = d + m / 60 + s / 3600; + return negative ? -degrees : degrees; +}; + +// The common fields of a JPEG's EXIF block: `{}` when the bytes are not a JPEG, +// have no EXIF block, or exif-reader cannot parse it. +export const readPhotoExif = (bytes: Uint8Array): PhotoExif => { + const { exif } = extractExifFromJpeg(bytes); + if (exif === undefined) return {}; + let tags: ReturnType; + try { + tags = exifReader(exif); + } catch { + return {}; + } + const image = tags.Image ?? {}; + const photo = tags.Photo ?? {}; + const gps = tags.GPSInfo ?? {}; + const altitude = asNumber(gps.GPSAltitude); + const fields: PhotoExif = { + make: asString(image.Make), + model: asString(image.Model), + lensModel: asString(photo.LensModel), + dateTimeOriginal: asDate(photo.DateTimeOriginal), + offsetTimeOriginal: asString(photo.OffsetTimeOriginal), + exposureTime: asNumber(photo.ExposureTime), + fNumber: asNumber(photo.FNumber), + iso: asNumber(photo.ISOSpeedRatings), + focalLength: asNumber(photo.FocalLength), + orientation: asNumber(image.Orientation), + gpsLatitude: asDegrees(gps.GPSLatitude, gps.GPSLatitudeRef === "S"), + gpsLongitude: asDegrees(gps.GPSLongitude, gps.GPSLongitudeRef === "W"), + // A GPSAltitudeRef of 1 means the altitude is below sea level. + gpsAltitude: + altitude !== undefined && gps.GPSAltitudeRef === 1 + ? -altitude + : altitude, + }; + // Leave out what the file lacks, so a missing field is absent rather than + // present and undefined. + return Object.fromEntries( + Object.entries(fields).filter(([, v]) => v !== undefined), + ) as PhotoExif; +}; diff --git a/src/index.ts b/src/index.ts index a0a4186..b5ebfe1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -84,6 +84,7 @@ export type { LibrarySnapshot, LibraryChange, } from "./library/records.js"; +export type { PhotoExif } from "./exif.js"; export { decryptCollection, decryptFile } from "./model/index.js"; export { downloadFile, downloadThumbnail } from "./download/index.js"; export type { diff --git a/src/library/content.ts b/src/library/content.ts index 2353fef..fea3b74 100644 --- a/src/library/content.ts +++ b/src/library/content.ts @@ -108,6 +108,11 @@ export interface ContentOptions { export interface PhotoContent { original(fileID: number, opts?: ContentOptions): Promise; thumbnail(fileID: number, opts?: ContentOptions): Promise; + // Where a backup stores the original, whether or not it is there yet. For + // a live photo not yet stored, it carries the title's extension, and the + // backup may store the image under a different one. + savePath(fileID: number): string | undefined; + isLocal(fileID: number): boolean; } export interface EnsureResult { @@ -435,6 +440,31 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI { return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress); } + // Where a backup to the download directory stores the file's original, + // whether or not it is there yet: for a live photo already stored, its + // image. For a live photo not yet stored, it carries the title's + // extension, and the backup may store the image under a different one. + // Undefined with no download directory. + savePath(fileID: number): string | undefined { + const file = this.getFile(fileID); + if (this.downloadDirectory === undefined || file === undefined) + return undefined; + const dir = join(this.downloadDirectory, "originals"); + return ( + storedOriginal(dir, file)?.path ?? join(dir, nameInOriginals(file)) + ); + } + + // Whether the whole original is in the download directory, as a backup + // stores it. A copy only in the cache does not count. + isLocal(fileID: number): boolean { + const file = this.getFile(fileID); + if (this.downloadDirectory === undefined || file === undefined) + return false; + const dir = join(this.downloadDirectory, "originals"); + return storedOriginal(dir, file) !== undefined; + } + // Get an original for a backup. One not present anywhere is written // straight to `destination` and recorded there, so no second copy lands // in the cache; one already present is returned where it is. diff --git a/src/library/read.ts b/src/library/read.ts index 09b95cf..a10731d 100644 --- a/src/library/read.ts +++ b/src/library/read.ts @@ -11,11 +11,15 @@ // access and, for an album, its photos. They are not sent across IPC — the // plain records are the serializable surface, and `record()` returns one. // -// A `Photo` also fetches its own bytes: `original()` and `thumbnail()` go -// through the on-disk content cache (issue #46), the one place in this module -// that is not synchronous and RAM-only. A library opened without a content -// source leaves that cache absent, and those two methods then throw. +// A `Photo` also fetches its own bytes: `original()`, `thumbnail()`, +// `content()` and `exif()` go through the on-disk content cache (issue #46), +// and are the one place in this module that may touch the network. A library +// opened without a content source leaves that cache absent, and those methods +// then throw. `savePath` and `isLocal` look only at the disk. +import { readFile } from "node:fs/promises"; + +import { readPhotoExif, type PhotoExif } from "../exif.js"; import type { CollectionType, FileType } from "../model/types.js"; import type { ContentOptions, ContentResult, PhotoContent } from "./content.js"; import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js"; @@ -35,7 +39,7 @@ const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number => export class Photo { constructor( private readonly rec: PhotoRecord, - private readonly content?: PhotoContent, + private readonly cache?: PhotoContent, ) {} get fileID(): number { @@ -50,6 +54,13 @@ export class Photo { get takenAt(): number { return this.rec.takenAt; } + get modifiedAt(): number { + return this.rec.modifiedAt; + } + // The local-time year of `takenAt`. + get year(): number { + return new Date(this.rec.takenAt).getFullYear(); + } get fileType(): FileType { return this.rec.fileType; } @@ -68,6 +79,9 @@ export class Photo { get longitude(): number | undefined { return this.rec.longitude; } + get hash(): string | undefined { + return this.rec.hash; + } get isArchived(): boolean { return this.rec.isArchived; } @@ -75,6 +89,22 @@ export class Photo { return this.rec.isHidden; } + // Where `lib.backup()` stores the original in the library's download + // directory, whether or not it is there yet: for a live photo already + // stored, its image. For a live photo not yet stored, it carries the + // title's extension, and the backup may store the image under a different + // one. Undefined when the library has no download directory or no content + // cache. + get savePath(): string | undefined { + return this.cache?.savePath(this.rec.fileID); + } + + // Whether the whole original is at `savePath`. A copy only in the cache + // does not count. + get isLocal(): boolean { + return this.cache?.isLocal(this.rec.fileID) ?? false; + } + record(): PhotoRecord { return this.rec; } @@ -84,21 +114,38 @@ export class Photo { // `videoPath`. Served from the cache (or the backup download directory) // when already present, otherwise fetched through the content pool. async original(opts?: ContentOptions): Promise { - return this.contentOrThrow().original(this.rec.fileID, opts); + return this.cacheOrThrow().original(this.rec.fileID, opts); } // As `original`, for the thumbnail, through the thumbnail pool. async thumbnail(opts?: ContentOptions): Promise { - return this.contentOrThrow().thumbnail(this.rec.fileID, opts); + return this.cacheOrThrow().thumbnail(this.rec.fileID, opts); } - private contentOrThrow(): PhotoContent { - if (!this.content) { + // The original's bytes, read from where `original()` puts it. For a live + // photo, its image's. + async content(opts?: ContentOptions): Promise { + const { path } = await this.original(opts); + return readFile(path); + } + + // The common EXIF fields of the original, read from `content()`, so this + // may download it. Only a JPEG's EXIF is read; any other file gives `{}`, + // and a video gives it without fetching anything. Like the other content + // methods, it throws when there is no content cache, video or not. + async exif(opts?: ContentOptions): Promise { + this.cacheOrThrow(); + if (this.rec.fileType === "video") return {}; + return readPhotoExif(await this.content(opts)); + } + + private cacheOrThrow(): PhotoContent { + if (!this.cache) { throw new Error( "Photo content requires a library opened with a content cache", ); } - return this.content; + return this.cache; } } diff --git a/src/library/records.ts b/src/library/records.ts index c06681f..5136ddb 100644 --- a/src/library/records.ts +++ b/src/library/records.ts @@ -5,10 +5,11 @@ // owner ruling 5). The decrypted `Collection`/`EnteFile` objects stay in RAM in // the main process; the window only ever sees these records. // -// Ente holds edited/basic times in microseconds; records expose `takenAt` in -// milliseconds. The magic-metadata field names below are the ones the Ente -// clients write, confirmed against the repo's own fixtures: `w`/`h` in -// test/cli/metadata-backup.test.ts, `visibility` in test/library/store.test.ts. +// Ente holds edited/basic times in microseconds; records expose `takenAt` and +// `modifiedAt` in milliseconds. The magic-metadata field names below are the +// ones the Ente clients write, confirmed against the repo's own fixtures: +// `w`/`h` in test/cli/metadata-backup.test.ts, `visibility` in +// test/library/store.test.ts. import type { Collection, @@ -33,12 +34,17 @@ export interface PhotoRecord { // Milliseconds. `pubMagicMetadata.editedTime` when the user edited the // date, else basic-metadata `creationTime`. takenAt: number; + // Milliseconds. Basic-metadata `modificationTime`. + modifiedAt: number; fileType: FileType; caption?: string; width?: number; height?: number; latitude?: number; longitude?: number; + // The content hash the uploader recorded (`FileMetadata.hash`); files from + // very old clients have none. + hash?: string; isArchived: boolean; isHidden: boolean; // Local cache paths, set once a later phase caches the bytes; unset here. @@ -125,6 +131,7 @@ const toPhotoRecord = ( albumIDs, title: asString(pub.editedName) ?? rep.metadata.title, takenAt: microsToMillis(takenAtMicros), + modifiedAt: microsToMillis(rep.metadata.modificationTime), fileType: rep.metadata.fileType, isArchived: visibility === VISIBILITY_ARCHIVED, isHidden: visibility === VISIBILITY_HIDDEN, @@ -140,6 +147,7 @@ const toPhotoRecord = ( record.latitude = rep.metadata.latitude; if (rep.metadata.longitude !== undefined) record.longitude = rep.metadata.longitude; + if (rep.metadata.hash !== undefined) record.hash = rep.metadata.hash; return record; }; diff --git a/src/metadata-backup.ts b/src/metadata-backup.ts index 2bee63e..420fb9e 100644 --- a/src/metadata-backup.ts +++ b/src/metadata-backup.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import * as jpeg from "jpeg-js"; import exifReader from "exif-reader"; import type { Client } from "./client.js"; +import { extractExifFromJpeg } from "./exif.js"; import type { Library, Photo } from "./library/index.js"; import { sanitizeFileName } from "./filename.js"; import { @@ -19,60 +20,6 @@ export interface MetadataBackupOptions { onProgress?: ProgressCallback; } -// Find the raw EXIF APP1 segment in JPEG bytes. Returns `exif` (the segment -// data, starting at the "Exif\0\0" header) when there is one, nothing when the -// bytes are not a JPEG or carry no EXIF, and `error` when the segment layout is -// malformed. Each segment length is checked against the bytes that remain and -// each step moves forward by at least 4 bytes, so the scan ends on any input. -export const extractExifFromJpeg = ( - buf: Uint8Array, -): { exif?: Buffer; error?: string } => { - if (buf[0] !== 0xff || buf[1] !== 0xd8) return {}; - let offset = 2; - while (offset < buf.length) { - if (offset + 2 > buf.length) - return { error: `truncated segment marker at byte ${offset}` }; - if (buf[offset] !== 0xff) - return { error: `no segment marker at byte ${offset}` }; - const marker = buf[offset + 1]!; - if (marker === 0xda) return {}; // start of scan, no more markers - if (offset + 4 > buf.length) - return { error: `truncated segment length at byte ${offset}` }; - const len = (buf[offset + 2]! << 8) | buf[offset + 3]!; - // The length counts its own two bytes, so anything under 2 is invalid. - if (len < 2) - return { - error: `segment length ${len} at byte ${offset} is too small`, - }; - if (offset + 2 + len > buf.length) - return { - error: `segment length ${len} at byte ${offset} runs past the end of the file`, - }; - if (marker === 0xe1) { - // APP1 — check for "Exif\0\0" header. A length under 8 cannot hold - // the six-byte header, so the segment is not EXIF; below 6 the - // bytes compared would also lie past the segment. - if ( - len >= 8 && - buf[offset + 4] === 0x45 && - buf[offset + 5] === 0x78 && - buf[offset + 6] === 0x69 && - buf[offset + 7] === 0x66 - ) { - return { - exif: Buffer.from( - buf.buffer, - buf.byteOffset + offset + 4, - len - 2, - ), - }; - } - } - offset += 2 + len; - } - return { error: "file ends before the image data" }; -}; - // Extract dimensions, EXIF and XMP from a file's bytes. When the EXIF segment // is malformed or cannot be parsed, the record carries the reason in // `exifError`. diff --git a/test/cli/metadata-exif.test.ts b/test/cli/metadata-exif.test.ts index 4a8cf55..870826c 100644 --- a/test/cli/metadata-exif.test.ts +++ b/test/cli/metadata-exif.test.ts @@ -8,10 +8,8 @@ */ import { describe, expect, it } from "vitest"; -import { - extractExifFromJpeg, - extractImageMetadata, -} from "../../src/metadata-backup.js"; +import { extractExifFromJpeg } from "../../src/exif.js"; +import { extractImageMetadata } from "../../src/metadata-backup.js"; const SOI = [0xff, 0xd8]; // start of image const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan, where the scan stops diff --git a/test/library/content-library.test.ts b/test/library/content-library.test.ts index bebc9c4..debd23c 100644 --- a/test/library/content-library.test.ts +++ b/test/library/content-library.test.ts @@ -6,7 +6,8 @@ * `Photo` objects that fetch through it, `lib.thumbnails.ensure` drives it, and * a cached path shows up on the projected record. A library opened without a * content source leaves those methods throwing rather than silently doing - * nothing. + * nothing. It also covers a `Photo`'s `savePath`, `isLocal`, `content()` and + * `exif()`. */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; @@ -24,7 +25,7 @@ import { Library, type LibraryOptions } from "../../src/library/index.js"; import type { ContentSource } from "../../src/library/content.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; -import { asLivePhoto, cdnSource, livePhotoZip } from "../live-photo.js"; +import { asLivePhoto, cdnSource, IMAGE, livePhotoZip } from "../live-photo.js"; const USER_ID = 7; @@ -70,14 +71,23 @@ class MockClient { } } -// A content source that writes a marker file and counts thumbnail fetches. -const stubSource = (): ContentSource & { thumbCalls: () => number } => { +// A content source that writes `original` as every original and a marker file +// as every thumbnail, and counts the fetches of each. +const stubSource = ( + original: string | Uint8Array = "orig-bytes", +): ContentSource & { + originalCalls: () => number; + thumbCalls: () => number; +} => { + let originalCalls = 0; let thumbCalls = 0; return { + originalCalls: () => originalCalls, thumbCalls: () => thumbCalls, original: async ({ destination }) => { - writeFileSync(destination, "orig-bytes"); - return { bytesWritten: 10 }; + originalCalls++; + writeFileSync(destination, original); + return { bytesWritten: original.length }; }, thumbnail: async ({ destination }) => { thumbCalls++; @@ -211,3 +221,233 @@ describe("Library content wiring", () => { await lib.close(); }); }); + +// Big-endian bytes for the hand-built JPEG below. +const u16 = (n: number): number[] => [n >> 8, n & 0xff]; +const u32 = (n: number): number[] => [...u16(n >>> 16), ...u16(n & 0xffff)]; +const ascii = (s: string): number[] => [...new TextEncoder().encode(s), 0]; +const rational = (num: number, den: number): number[] => [ + ...u32(num), + ...u32(den), +]; +// One IFD entry: tag, type (1 BYTE, 2 ASCII, 3 SHORT, 4 LONG, 5 RATIONAL), +// count, then the value when it fits in 4 bytes, else its offset. +const entry = ( + tag: number, + type: number, + count: number, + value: number[], +): number[] => [...u16(tag), ...u16(type), ...u32(count), ...value]; + +// The TIFF block of a JPEG's EXIF segment, holding every field `exif()` picks: +// the camera in the first IFD, the exposure in the Exif IFD, and a GPS position +// of 40°26'46" N, 79°58'56" W, 12.5 m below sea level. Offsets count from the +// start of this block. +const TIFF = [ + ...[0x4d, 0x4d, 0x00, 0x2a], // big-endian TIFF + ...u32(8), // the first IFD's offset + // The first IFD, at 8: five entries, then no next IFD. + ...u16(5), + ...entry(0x010f, 2, 6, u32(74)), // Make + ...entry(0x0110, 2, 7, u32(80)), // Model + ...entry(0x0112, 3, 1, [...u16(6), 0, 0]), // Orientation + ...entry(0x8769, 4, 1, u32(88)), // the Exif IFD's offset + ...entry(0x8825, 4, 1, u32(246)), // the GPS IFD's offset + ...u32(0), + ...ascii("Canon"), // at 74 + ...ascii("EOS R5"), // at 80 + 0, // a pad byte + // The Exif IFD, at 88: seven entries, then no next IFD. + ...u16(7), + ...entry(0x829a, 5, 1, u32(178)), // ExposureTime + ...entry(0x829d, 5, 1, u32(186)), // FNumber + ...entry(0x8827, 3, 1, [...u16(400), 0, 0]), // ISOSpeedRatings + ...entry(0x9003, 2, 20, u32(194)), // DateTimeOriginal + ...entry(0x9011, 2, 7, u32(214)), // OffsetTimeOriginal + ...entry(0x920a, 5, 1, u32(222)), // FocalLength + ...entry(0xa434, 2, 16, u32(230)), // LensModel + ...u32(0), + ...rational(1, 250), // at 178 + ...rational(28, 10), // at 186 + ...ascii("2021:07:15 14:30:00"), // at 194 + ...ascii("+02:00"), // at 214 + 0, // a pad byte + ...rational(50, 1), // at 222 + ...ascii("RF50mm F1.8 STM"), // at 230 + // The GPS IFD, at 246: six entries, then no next IFD. + ...u16(6), + ...entry(0x0001, 2, 2, [...ascii("N"), 0, 0]), // GPSLatitudeRef + ...entry(0x0002, 5, 3, u32(324)), // GPSLatitude + ...entry(0x0003, 2, 2, [...ascii("W"), 0, 0]), // GPSLongitudeRef + ...entry(0x0004, 5, 3, u32(348)), // GPSLongitude + ...entry(0x0005, 1, 1, [1, 0, 0, 0]), // GPSAltitudeRef: below sea level + ...entry(0x0006, 5, 1, u32(372)), // GPSAltitude + ...u32(0), + ...[...rational(40, 1), ...rational(26, 1), ...rational(46, 1)], // at 324 + ...[...rational(79, 1), ...rational(58, 1), ...rational(56, 1)], // at 348 + ...rational(25, 2), // at 372 +]; + +const JPEG_WITH_EXIF = new Uint8Array([ + ...[0xff, 0xd8], // start of image + ...[0xff, 0xe1, ...u16(2 + 6 + TIFF.length)], // APP1 and its length + ...[...ascii("Exif"), 0], // "Exif\0\0" + ...TIFF, + ...[0xff, 0xda, 0x00, 0x02], // start of scan +]); + +// A JPEG whose EXIF segment is laid out correctly but holds "XX" where the TIFF +// byte order belongs, so exif-reader cannot parse it. +const JPEG_WITH_BAD_EXIF = new Uint8Array([ + ...[0xff, 0xd8], // start of image + ...[0xff, 0xe1, ...u16(2 + 6 + 2)], // APP1 and its length + ...[...ascii("Exif"), 0], // "Exif\0\0" + ...[0x58, 0x58], // "XX" + ...[0xff, 0xda, 0x00, 0x02], // start of scan +]); + +describe("Photo save path, local copy, content and EXIF", () => { + // The same account, with `files` in its album instead. + class FilesClient extends MockClient { + constructor(private readonly files: EnteFile[]) { + super(); + } + override async filesSince(): Promise { + return { files: this.files, deleted: [], cursor: 1 }; + } + } + + const open = (opts: Partial = {}): Promise => + Library.open({ + client: new MockClient(), + cacheDirectory: join(root, "cache"), + downloadDirectory: join(root, "backup"), + contentSource: stubSource(), + refreshIntervalSeconds: 3600, + precacheThumbnails: false, + precacheOriginals: false, + ...opts, + }); + + it("names where a backup writes the original, which is local once the backup has written it", async () => { + const lib = await open(); + const photo = lib.photos.byID({ fileID: 1 })!; + const savePath = join(root, "backup", "originals", "1.jpg"); + expect(photo.savePath).toBe(savePath); + expect(photo.isLocal).toBe(false); + + await lib.backup(); + expect(photo.savePath).toBe(savePath); + expect(existsSync(savePath)).toBe(true); + expect(photo.isLocal).toBe(true); + await lib.close(); + }); + + it("returns the original's bytes, and a copy only in the cache is not local", async () => { + const lib = await open(); + const photo = lib.photos.byID({ fileID: 1 })!; + expect(await photo.content()).toEqual(Buffer.from("orig-bytes")); + expect(existsSync(join(root, "cache", "originals", "1.jpg"))).toBe( + true, + ); + expect(existsSync(photo.savePath!)).toBe(false); + expect(photo.isLocal).toBe(false); + await lib.close(); + }); + + it("has no save path and is not local without a download directory", async () => { + const lib = await open({ downloadDirectory: undefined }); + const photo = lib.photos.byID({ fileID: 1 })!; + expect(photo.savePath).toBeUndefined(); + expect(photo.isLocal).toBe(false); + await lib.close(); + }); + + it("has no save path and is not local without a content source", async () => { + const lib = await open({ contentSource: undefined }); + const photo = lib.photos.byID({ fileID: 1 })!; + expect(photo.savePath).toBeUndefined(); + expect(photo.isLocal).toBe(false); + await lib.close(); + }); + + it("gives a live photo's image as its save path once a backup has stored it", async () => { + const { file: live, body } = await asLivePhoto(file(1, 1)); + const lib = await open({ + client: new FilesClient([live]), + contentSource: cdnSource(new Map([[1, body]])), + }); + const photo = lib.photos.byID({ fileID: 1 })!; + const originals = join(root, "backup", "originals"); + // Until then the name comes from the title, file-1.jpg; the backup + // stores the image with the extension found inside the live photo. + expect(photo.savePath).toBe(join(originals, "1.jpg")); + + await lib.backup(); + expect(photo.savePath).toBe(join(originals, "1.heic")); + expect(photo.isLocal).toBe(true); + expect(await photo.content()).toEqual(Buffer.from(IMAGE)); + await lib.close(); + }); + + it("reads the common EXIF fields of a JPEG original", async () => { + const lib = await open({ contentSource: stubSource(JPEG_WITH_EXIF) }); + expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({ + make: "Canon", + model: "EOS R5", + lensModel: "RF50mm F1.8 STM", + // The camera's clock reading, held in the Date's UTC fields. + dateTimeOriginal: new Date(Date.UTC(2021, 6, 15, 14, 30)), + offsetTimeOriginal: "+02:00", + exposureTime: 1 / 250, + fNumber: 2.8, + iso: 400, + focalLength: 50, + orientation: 6, + gpsLatitude: 40 + 26 / 60 + 46 / 3600, + gpsLongitude: -(79 + 58 / 60 + 56 / 3600), + gpsAltitude: -12.5, + }); + await lib.close(); + }); + + it("returns no EXIF fields for an original that is not a JPEG", async () => { + const lib = await open(); + expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({}); + await lib.close(); + }); + + it("returns no EXIF fields for a JPEG whose EXIF cannot be parsed", async () => { + const lib = await open({ + contentSource: stubSource(JPEG_WITH_BAD_EXIF), + }); + expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({}); + await lib.close(); + }); + + it("throws from exif() on a video without a content source, as the other content methods do", async () => { + const video = file(1, 1); + video.metadata.fileType = "video"; + const lib = await open({ + client: new FilesClient([video]), + contentSource: undefined, + }); + await expect(lib.photos.byID({ fileID: 1 })!.exif()).rejects.toThrow( + /content cache/i, + ); + await lib.close(); + }); + + it("returns no EXIF fields for a video, without fetching it", async () => { + const video = file(1, 1); + video.metadata.fileType = "video"; + const source = stubSource(JPEG_WITH_EXIF); + const lib = await open({ + client: new FilesClient([video]), + contentSource: source, + }); + expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({}); + expect(source.originalCalls()).toBe(0); + await lib.close(); + }); +}); diff --git a/test/library/read.test.ts b/test/library/read.test.ts index 2fe394a..4bb7d45 100644 --- a/test/library/read.test.ts +++ b/test/library/read.test.ts @@ -209,6 +209,29 @@ describe("lib.photos", () => { expect("key" in photo.record()).toBe(false); }); + it("byID exposes modifiedAt, hash and the year taken", () => { + // Mid-July, so the year is 2021 in every time zone. + const takenAt = Date.UTC(2021, 6, 15, 12); + const records = deriveRecords( + [collection(1)], + [ + file(1001, 1, { + metadata: { + title: "IMG.jpg", + fileType: "image", + creationTime: micros(takenAt), + modificationTime: micros(1_700_000_123_456), + hash: "aGFzaA==", + }, + }), + ], + ); + const photo = apis(records).photos.byID({ fileID: 1001 })!; + expect(photo.modifiedAt).toBe(ms(1_700_000_123_456)); + expect(photo.hash).toBe("aGFzaA=="); + expect(photo.year).toBe(2021); + }); + it("byID returns undefined for an unknown file id", () => { const records = deriveRecords([collection(1)], [file(1, 1)]); expect(apis(records).photos.byID({ fileID: 999 })).toBeUndefined(); diff --git a/test/library/records.test.ts b/test/library/records.test.ts index cf4acd3..c05b09a 100644 --- a/test/library/records.test.ts +++ b/test/library/records.test.ts @@ -159,6 +159,7 @@ describe("deriveRecords: photo mapping", () => { expect("width" in rec).toBe(false); expect("height" in rec).toBe(false); expect("latitude" in rec).toBe(false); + expect("hash" in rec).toBe(false); }); it("reads archived and hidden from private magicMetadata.visibility", () => { From 67d554fb4637077389183915731d6c4fb77d1aaa Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 22:34:09 +0200 Subject: [PATCH 05/39] exif(): read HEIF/HEIC originals with exifreader (closes #145) `photo.exif()` and `quak backup-metadata --exif` now read EXIF through `exifreader`. HEIC/HEIF originals get EXIF, including a live photo's image, as do the other formats `exifreader` reads. It replaces `exif-reader` and the hand-written JPEG scan. `PhotoExif` is unchanged. The `backup-metadata` dump now holds `exifreader`'s tag output, with unnamed tags keyed `undefined-` plus their number. GPS altitude without a reference counts as above sea level. A latitude or longitude without its hemisphere tag, an unreadable text tag, and a date the parser rejects each give no field. Licence: `exifreader` is MPL-2.0, used unmodified. Model: opus-5-5 --- README.md | 20 ++- TODO.md | 8 + package.json | 2 +- src/exif.ts | 162 +++++++----------- src/library/read.ts | 7 +- src/metadata-backup.ts | 30 ++-- test/cli/commands.test.ts | 26 +++ test/cli/metadata-exif.test.ts | 246 +++++++++++++++++++-------- test/exif-heic.ts | 27 +++ test/exif.heic | Bin 0 -> 933 bytes test/library/content-library.test.ts | 57 ++++++- yarn.lock | 15 +- 12 files changed, 401 insertions(+), 199 deletions(-) create mode 100644 test/exif-heic.ts create mode 100644 test/exif.heic diff --git a/README.md b/README.md index a75f15b..80c573d 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ quak/ backup.ts resilient full-account backup with dedup metadata-backup.ts backup-metadata: the metadata quak keeps, as JSON - exif.ts EXIF read from a JPEG's bytes + exif.ts EXIF read from an image's bytes with exifreader mldata-fetch.ts fetch + decrypt per-file ML data filename.ts safe file names from server metadata errors.ts error types shared across layers @@ -495,10 +495,15 @@ Ente's clients name them (`IMG_0001.heic` and `IMG_0001.mov`). With `--out PATH`, the image is written to `PATH` and the video beside it, with `PATH`'s name and the video's extension; a `PATH` with the video's extension is refused. `backup-metadata --exif` (alias `--all`) additionally fetches each -file's original through the cache and records its XMP metadata and, for a JPEG, -its EXIF metadata and dimensions. `collections`, `files`, `backup`, -`helper list-missing-thumbnails` and `helper fix-missing-thumbnails` take -`--json` for machine-readable output. +file's original through the cache and records, from it or a live photo's image, +its XMP metadata, its EXIF metadata and, for a JPEG, its dimensions. EXIF is +read with [exifreader](https://github.com/mattiasw/ExifReader) from any image +format it reads, JPEG, HEIC/HEIF, AVIF, PNG and WebP among them. The record's +`exif` field is exifreader's EXIF tag output: each tag by name, with its +`value`, `description` and `computed` value. An EXIF block exifreader finds but +reads no tag from is recorded, base64, as `exifRaw`, with the reason in +`exifError`. `collections`, `files`, `backup`, `helper list-missing-thumbnails` +and `helper fix-missing-thumbnails` take `--json` for machine-readable output. `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 @@ -720,8 +725,9 @@ Four async methods may download: each absent when the file lacks it. GPS values are signed decimal degrees and metres. `dateTimeOriginal` is the camera's clock reading held in the `Date`'s UTC fields; `offsetTimeOriginal`, when present, is that clock's offset from - UTC. Only a JPEG's EXIF is read: any other original gives `{}`, and a video - gives `{}` without being downloaded. + UTC. EXIF is read from any image format exifreader reads (such as JPEG, + HEIC/HEIF, AVIF, PNG, WebP and TIFF), a live photo's image included. Any other + original gives `{}`, and a video gives `{}` without being downloaded. They serve from the on-disk content cache when the bytes are present and otherwise fetch through the pools; `original()`, `content()` and `exif()` also diff --git a/TODO.md b/TODO.md index 4c188b5..b342066 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,14 @@ declares one. # Completed Steps +- 2026-10-01: `photo.exif()` and `backup-metadata --exif` read EXIF from + HEIC/HEIF originals, a live photo's HEIC image included, as well as JPEG and + the other image formats `exifreader` reads (issue 145). `exifreader` replaces + `exif-reader` and the JPEG segment scan; `PhotoExif` is unchanged. The `exif` + field of `backup-metadata --exif` is now exifreader's tag output, and + `exifRaw` holds the whole EXIF block it could not read. The tests use a real + HEIC, `test/exif.heic`. + - 2026-10-01: A `Photo` has `savePath`, `isLocal`, `content()`, `exif()`, `modifiedAt`, `hash` and `year` (issue 141). `savePath` is where `lib.backup()` writes the original under the library's download directory; for diff --git a/package.json b/package.json index f1a1d6f..779a239 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@inquirer/prompts": "8.5.2", "commander": "14.0.3", "env-paths": "4.0.0", - "exif-reader": "2.0.3", + "exifreader": "4.46.0", "fast-srp-hap": "2.0.4", "fflate": "0.8.3", "jpeg-js": "0.4.4", diff --git a/src/exif.ts b/src/exif.ts index 778cbfa..b0f13f5 100644 --- a/src/exif.ts +++ b/src/exif.ts @@ -1,60 +1,34 @@ -// EXIF in a JPEG's bytes. `backup-metadata --exif` records the whole EXIF block -// it finds; `Photo.exif()` returns the common fields picked from it here. +// EXIF in an original's bytes, read with exifreader, which reads it from JPEG, +// HEIC/HEIF, AVIF, PNG, WebP and the other image formats it supports. +// `backup-metadata --exif` records every EXIF tag it finds except the +// thumbnail's; `Photo.exif()` returns the common fields picked from them here. -import exifReader from "exif-reader"; +import ExifReader, { type ExpandedTags } from "exifreader"; -// Find the raw EXIF APP1 segment in JPEG bytes. Returns `exif` (the segment -// data, starting at the "Exif\0\0" header) when there is one, nothing when the -// bytes are not a JPEG or carry no EXIF, and `error` when the segment layout is -// malformed. Each segment length is checked against the bytes that remain and -// each step moves forward by at least 4 bytes, so the scan ends on any input. -export const extractExifFromJpeg = ( - buf: Uint8Array, -): { exif?: Buffer; error?: string } => { - if (buf[0] !== 0xff || buf[1] !== 0xd8) return {}; - let offset = 2; - while (offset < buf.length) { - if (offset + 2 > buf.length) - return { error: `truncated segment marker at byte ${offset}` }; - if (buf[offset] !== 0xff) - return { error: `no segment marker at byte ${offset}` }; - const marker = buf[offset + 1]!; - if (marker === 0xda) return {}; // start of scan, no more markers - if (offset + 4 > buf.length) - return { error: `truncated segment length at byte ${offset}` }; - const len = (buf[offset + 2]! << 8) | buf[offset + 3]!; - // The length counts its own two bytes, so anything under 2 is invalid. - if (len < 2) - return { - error: `segment length ${len} at byte ${offset} is too small`, - }; - if (offset + 2 + len > buf.length) - return { - error: `segment length ${len} at byte ${offset} runs past the end of the file`, - }; - if (marker === 0xe1) { - // APP1 — check for "Exif\0\0" header. A length under 8 cannot hold - // the six-byte header, so the segment is not EXIF; below 6 the - // bytes compared would also lie past the segment. - if ( - len >= 8 && - buf[offset + 4] === 0x45 && - buf[offset + 5] === 0x78 && - buf[offset + 6] === 0x69 && - buf[offset + 7] === 0x66 - ) { - return { - exif: Buffer.from( - buf.buffer, - buf.byteOffset + offset + 4, - len - 2, - ), - }; - } - } - offset += 2 + len; +// The EXIF tags in `bytes` (`exif`), the GPS position exifreader computes from +// them (`gps`), and where the EXIF block lies in `bytes` (`metadataRange`). +// Undefined when exifreader cannot read the file at all, such as a video. An +// EXIF block it finds but reads no tag from comes back as an empty `exif`. +// `exif` holds every tag except the thumbnail's; a tag exifreader has no name +// for is keyed `undefined-`. Each tag's `computed` holds its value +// as a string or number, or as an array of them for a tag with several values, +// such as `GPSLatitude`'s `[40, 26, 46]`. A fraction with a zero denominator +// computes to null. +export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => { + try { + return ExifReader.loadView( + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength), + { + expanded: true, + computed: true, + includeOffsets: true, + includeUnknown: true, + includeTags: { exif: true, gps: true }, + }, + ); + } catch { + return undefined; } - return { error: "file ends before the image data" }; }; // The common EXIF fields of an original. Each is absent when the file lacks it. @@ -63,9 +37,9 @@ export interface PhotoExif { model?: string; lensModel?: string; // When the photo was taken, by the camera's clock. EXIF writes this as text - // with no time zone, and exif-reader reads that text as if it were UTC: the - // Date's UTC fields are the clock reading, which is the moment it was taken - // only when the clock was set to UTC. + // with no time zone, and it is read as if it were UTC: the Date's UTC + // fields are the clock reading, which is the moment it was taken only when + // the clock was set to UTC. dateTimeOriginal?: Date; // The camera clock's offset from UTC, such as "+02:00". offsetTimeOriginal?: string; @@ -84,56 +58,52 @@ export interface PhotoExif { gpsAltitude?: number; } +// exifreader gives "" for a tag whose value lies outside the +// file; that tag is left out like one the file lacks. const asString = (v: unknown): string | undefined => - typeof v === "string" && v.length > 0 ? v : undefined; + typeof v === "string" && v.length > 0 && v !== "" + ? v + : undefined; const asNumber = (v: unknown): number | undefined => typeof v === "number" && Number.isFinite(v) ? v : undefined; -const asDate = (v: unknown): Date | undefined => - v instanceof Date && !Number.isNaN(v.getTime()) ? v : undefined; - -// EXIF writes a GPS coordinate as three numbers: degrees, minutes and seconds. -// This is them as decimal degrees, negated when `negative`. -const asDegrees = (v: unknown, negative: boolean): number | undefined => { - if (!Array.isArray(v) || v.length !== 3) return undefined; - const [d, m, s] = v.map(asNumber); - if (d === undefined || m === undefined || s === undefined) return undefined; - const degrees = d + m / 60 + s / 3600; - return negative ? -degrees : degrees; +// EXIF writes a date and time as "2021:07:15 14:30:00". This is that reading +// in a Date's UTC fields. +const asDate = (v: unknown): Date | undefined => { + const m = + typeof v === "string" + ? /^(\d{4}):(\d{2}):(\d{2}) (\d{2}:\d{2}:\d{2})$/.exec(v) + : null; + if (!m) return undefined; + const date = new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}Z`); + return Number.isNaN(date.getTime()) ? undefined : date; }; -// The common fields of a JPEG's EXIF block: `{}` when the bytes are not a JPEG, -// have no EXIF block, or exif-reader cannot parse it. +// The common fields of an original's EXIF: `{}` when the file has no EXIF, +// exifreader cannot read its EXIF, or it is not an image exifreader reads. export const readPhotoExif = (bytes: Uint8Array): PhotoExif => { - const { exif } = extractExifFromJpeg(bytes); - if (exif === undefined) return {}; - let tags: ReturnType; - try { - tags = exifReader(exif); - } catch { - return {}; - } - const image = tags.Image ?? {}; - const photo = tags.Photo ?? {}; - const gps = tags.GPSInfo ?? {}; - const altitude = asNumber(gps.GPSAltitude); + const tags = readExifTags(bytes); + const exif = tags?.exif; + const gps = tags?.gps; + const altitude = asNumber(exif?.GPSAltitude?.computed); const fields: PhotoExif = { - make: asString(image.Make), - model: asString(image.Model), - lensModel: asString(photo.LensModel), - dateTimeOriginal: asDate(photo.DateTimeOriginal), - offsetTimeOriginal: asString(photo.OffsetTimeOriginal), - exposureTime: asNumber(photo.ExposureTime), - fNumber: asNumber(photo.FNumber), - iso: asNumber(photo.ISOSpeedRatings), - focalLength: asNumber(photo.FocalLength), - orientation: asNumber(image.Orientation), - gpsLatitude: asDegrees(gps.GPSLatitude, gps.GPSLatitudeRef === "S"), - gpsLongitude: asDegrees(gps.GPSLongitude, gps.GPSLongitudeRef === "W"), + make: asString(exif?.Make?.computed), + model: asString(exif?.Model?.computed), + lensModel: asString(exif?.LensModel?.computed), + dateTimeOriginal: asDate(exif?.DateTimeOriginal?.computed), + offsetTimeOriginal: asString(exif?.OffsetTimeOriginal?.computed), + exposureTime: asNumber(exif?.ExposureTime?.computed), + fNumber: asNumber(exif?.FNumber?.computed), + // Only when the tag holds a single number, as most cameras write it. + iso: asNumber(exif?.ISOSpeedRatings?.computed), + focalLength: asNumber(exif?.FocalLength?.computed), + orientation: asNumber(exif?.Orientation?.computed), + gpsLatitude: asNumber(gps?.Latitude), + gpsLongitude: asNumber(gps?.Longitude), // A GPSAltitudeRef of 1 means the altitude is below sea level. gpsAltitude: - altitude !== undefined && gps.GPSAltitudeRef === 1 + altitude !== undefined && exif?.GPSAltitudeRef?.value === 1 ? -altitude : altitude, }; diff --git a/src/library/read.ts b/src/library/read.ts index a10731d..68a8dd2 100644 --- a/src/library/read.ts +++ b/src/library/read.ts @@ -130,9 +130,10 @@ export class Photo { } // The common EXIF fields of the original, read from `content()`, so this - // may download it. Only a JPEG's EXIF is read; any other file gives `{}`, - // and a video gives it without fetching anything. Like the other content - // methods, it throws when there is no content cache, video or not. + // may download it. EXIF is read from any image format exifreader reads, + // JPEG and HEIC/HEIF among them; any other file gives `{}`, and a video + // gives it without fetching anything. Like the other content methods, it + // throws when there is no content cache, video or not. async exif(opts?: ContentOptions): Promise { this.cacheOrThrow(); if (this.rec.fileType === "video") return {}; diff --git a/src/metadata-backup.ts b/src/metadata-backup.ts index 420fb9e..d453bf8 100644 --- a/src/metadata-backup.ts +++ b/src/metadata-backup.ts @@ -1,9 +1,8 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import * as jpeg from "jpeg-js"; -import exifReader from "exif-reader"; import type { Client } from "./client.js"; -import { extractExifFromJpeg } from "./exif.js"; +import { readExifTags } from "./exif.js"; import type { Library, Photo } from "./library/index.js"; import { sanitizeFileName } from "./filename.js"; import { @@ -20,9 +19,10 @@ export interface MetadataBackupOptions { onProgress?: ProgressCallback; } -// Extract dimensions, EXIF and XMP from a file's bytes. When the EXIF segment -// is malformed or cannot be parsed, the record carries the reason in -// `exifError`. +// Extract dimensions, EXIF and XMP from a file's bytes. `exif` is the EXIF tags +// exifreader returns, from any image format it reads. When it finds an EXIF +// block but reads no tag from it, the record keeps the block's bytes, base64, +// in `exifRaw`, with the reason in `exifError`. export const extractImageMetadata = ( fileBytes: Uint8Array, ): Record | undefined => { @@ -39,19 +39,21 @@ export const extractImageMetadata = ( result.height = decoded.height; } catch { // Not every original is a JPEG (PNG, HEIC, video), so a failed decode - // is expected and only means no dimensions; a malformed JPEG is still + // is expected and only means no dimensions; unreadable EXIF is still // reported below through `exifError`. } - const { exif, error } = extractExifFromJpeg(fileBytes); - if (error) result.exifError = error; - if (exif) { - try { - result.exif = exifReader(exif); - } catch (err) { - result.exifRaw = exif.toString("base64"); - result.exifError = err instanceof Error ? err.message : String(err); + const tags = readExifTags(fileBytes); + if (tags?.exif && Object.keys(tags.exif).length > 0) { + result.exif = tags.exif; + } else if (tags?.exif) { + const block = tags.metadataRange?.blocks.find((b) => b.type === "exif"); + if (block) { + result.exifRaw = Buffer.from( + fileBytes.subarray(block.start, block.end), + ).toString("base64"); } + result.exifError = "no tag could be read from the EXIF block"; } // Extract XMP (look for "http://ns.adobe.com/xap" in the bytes) diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index 5b441e5..ff939ff 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -56,6 +56,7 @@ import type { ContentSource } from "../../src/library/content.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; import { init, toBase64 } from "../../src/crypto/index.js"; import { defaultCacheDirectory } from "../../src/library/index.js"; +import { HEIC_WITH_EXIF } from "../exif-heic.js"; import { asLivePhoto, cdnSource, @@ -653,6 +654,31 @@ describe("a live photo", () => { height: 4, }); }); + + it("backup-metadata --exif records the EXIF of a HEIC image", async () => { + const dir = join(root, "dump"); + + expect( + await backupMetadataCommand( + context(await livePhotoClient(HEIC_WITH_EXIF)), + dir, + { exif: true }, + ), + ).toBe(0); + + const record = JSON.parse( + readFileSync( + join(dir, "collections", "1-Vacation", "300.json"), + "utf-8", + ), + ); + expect(record.imageMetadata.exifError).toBeUndefined(); + expect(record.imageMetadata.exif).toMatchObject({ + Make: { value: ["Canon"] }, + Model: { value: ["EOS R5"] }, + DateTimeOriginal: { value: ["2021:07:15 14:30:00"] }, + }); + }); }); describe("backup", () => { diff --git a/test/cli/metadata-exif.test.ts b/test/cli/metadata-exif.test.ts index 870826c..273181c 100644 --- a/test/cli/metadata-exif.test.ts +++ b/test/cli/metadata-exif.test.ts @@ -1,19 +1,24 @@ /** - * Tests for the JPEG EXIF scan behind `quak backup-metadata --exif`. + * Tests for reading EXIF (`src/exif.ts`) and the image metadata + * `quak backup-metadata --exif` records. * - * The originals come from users' libraries, so a truncated or corrupt JPEG - * must neither hang the scan nor throw out of it, and a malformed file must be - * told apart from one that simply has no EXIF: the record carries the reason in - * `exifError`. Each input below is a short hand-built byte array. + * The originals come from users' libraries, so a truncated or corrupt file + * must neither hang the read nor throw out of it: `readPhotoExif` gives `{}`, + * and `backup-metadata` tells an EXIF block it cannot read apart from a file + * that simply has no EXIF, carrying the reason in `exifError`. Each JPEG below + * is a short hand-built byte array; the HEIC is a real file. */ import { describe, expect, it } from "vitest"; -import { extractExifFromJpeg } from "../../src/exif.js"; +import { readPhotoExif } from "../../src/exif.js"; import { extractImageMetadata } from "../../src/metadata-backup.js"; +import { HEIC_WITH_EXIF } from "../exif-heic.js"; const SOI = [0xff, 0xd8]; // start of image -const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan, where the scan stops +const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0" +const APP0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00]; +const ZERO_LENGTH_APP0 = [0xff, 0xe0, 0x00, 0x00]; // A big-endian TIFF block with one IFD entry: Orientation (0x0112), SHORT, 6. const TIFF_ORIENTATION_6 = [ @@ -22,6 +27,66 @@ const TIFF_ORIENTATION_6 = [ 0x00, 0x00, ]; +// A big-endian TIFF block holding Orientation 6 and DateTimeOriginal +// "0000:00:00 00:00:00", which a camera with an unset clock writes. +const TIFF_UNSET_DATE = [ + ...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8 + // The first IFD, at 8: two entries, then no next IFD. + ...[0x00, 0x02], + // Orientation (0x0112), SHORT, 6. + ...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00], + // The Exif IFD's offset (0x8769), LONG, 38. + ...[0x87, 0x69, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x26], + ...[0x00, 0x00, 0x00, 0x00], + // The Exif IFD, at 38: one entry, then no next IFD. + ...[0x00, 0x01], + // DateTimeOriginal (0x9003), 20 ASCII bytes at 56. + ...[0x90, 0x03, 0x00, 0x02, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x38], + ...[0x00, 0x00, 0x00, 0x00], + ...new TextEncoder().encode("0000:00:00 00:00:00\0"), // at 56 +]; + +// A big-endian TIFF block holding a GPSAltitude of 12.5 m and no +// GPSAltitudeRef. +const TIFF_ALTITUDE_WITHOUT_REF = [ + ...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8 + // The first IFD, at 8: one entry, then no next IFD. + ...[0x00, 0x01], + // The GPS IFD's offset (0x8825), LONG, 26. + ...[0x88, 0x25, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1a], + ...[0x00, 0x00, 0x00, 0x00], + // The GPS IFD, at 26: one entry, then no next IFD. + ...[0x00, 0x01], + // GPSAltitude (0x0006), one RATIONAL at 44. + ...[0x00, 0x06, 0x00, 0x05, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x2c], + ...[0x00, 0x00, 0x00, 0x00], + ...[0x00, 0x00, 0x00, 0x19, 0x00, 0x00, 0x00, 0x02], // 25/2, at 44 +]; + +// A big-endian TIFF block holding Orientation 6 and a Make whose value lies +// past the end of the file. +const TIFF_MAKE_PAST_END = [ + ...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8 + // The first IFD, at 8: two entries, then no next IFD. + ...[0x00, 0x02], + // Make (0x010f), 6 ASCII bytes at 4096. + ...[0x01, 0x0f, 0x00, 0x02, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x10, 0x00], + // Orientation (0x0112), SHORT, 6. + ...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00], + ...[0x00, 0x00, 0x00, 0x00], +]; + +// A big-endian TIFF block with one IFD entry that exifreader has no name for: +// tag 0xc000 (49152), SHORT, 7. +const TIFF_UNNAMED_TAG = [ + ...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8 + // The first IFD, at 8: one entry, then no next IFD. + ...[0x00, 0x01], + // Tag 0xc000, SHORT, 7. + ...[0xc0, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x07, 0x00, 0x00], + ...[0x00, 0x00, 0x00, 0x00], +]; + // An APP1 segment whose length field matches its data. const app1 = (data: number[]): number[] => { const len = data.length + 2; @@ -31,74 +96,87 @@ const app1 = (data: number[]): number[] => { const bytes = (...parts: number[][]): Uint8Array => new Uint8Array(parts.flat()); -describe("extractExifFromJpeg", () => { - it("returns the EXIF segment of a valid JPEG", () => { +describe("readPhotoExif", () => { + it("reads the common fields of a valid JPEG", () => { const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6]; - const scan = extractExifFromJpeg(bytes(SOI, app1(data), SOS)); - expect(scan.error).toBeUndefined(); - expect([...scan.exif!]).toEqual(data); + expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ + orientation: 6, + }); }); - it("returns nothing for a file that is not a JPEG", () => { - const png = bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); - expect(extractExifFromJpeg(png)).toEqual({}); + it("gives no dateTimeOriginal for a DateTimeOriginal of 0000:00:00 00:00:00", () => { + const data = [...EXIF_HEADER, ...TIFF_UNSET_DATE]; + expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ + orientation: 6, + }); }); - it("returns nothing for a JPEG without EXIF", () => { - const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00]; - expect(extractExifFromJpeg(bytes(SOI, app0, SOS))).toEqual({}); + it("reads a GPSAltitude without GPSAltitudeRef as above sea level", () => { + const data = [...EXIF_HEADER, ...TIFF_ALTITUDE_WITHOUT_REF]; + expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ + gpsAltitude: 12.5, + }); }); - it("ignores an APP1 segment too short to hold the Exif header", () => { - // A length under 8 cannot hold the six-byte "Exif\0\0" header, so the - // segment is not EXIF. This one has length 7 and holds only "Exif\0", - // which the old code, lacking the length check, returned as EXIF. - const short = app1(EXIF_HEADER.slice(0, 5)); - expect(extractExifFromJpeg(bytes(SOI, short, SOS))).toEqual({}); + it("gives no make for a Make whose value lies past the end of the file", () => { + const data = [...EXIF_HEADER, ...TIFF_MAKE_PAST_END]; + expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ + orientation: 6, + }); }); - it("accepts an APP1 segment of length 8 holding just the Exif header", () => { - const scan = extractExifFromJpeg(bytes(SOI, app1(EXIF_HEADER), SOS)); - expect(scan.error).toBeUndefined(); - expect([...scan.exif!]).toEqual(EXIF_HEADER); - }); - - it("reports a JPEG truncated inside a segment header", () => { - const scan = extractExifFromJpeg(bytes(SOI, [0xff, 0xe1, 0x00])); - expect(scan.exif).toBeUndefined(); - expect(scan.error).toMatch(/truncated segment length/); - }); - - it("reports a JPEG that ends before the image data", () => { - const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00]; - const scan = extractExifFromJpeg(bytes(SOI, app0)); - expect(scan.error).toMatch(/ends before the image data/); - }); - - it("stops on a zero-length segment instead of looping", () => { - // A length of 0 would otherwise step the scan by 2 bytes at a time - // through the rest of the file, reading garbage as markers. - const zero = [0xff, 0xe0, 0x00, 0x00]; - const scan = extractExifFromJpeg( - bytes(SOI, zero, zero, zero, zero, SOS), - ); - expect(scan.error).toMatch(/segment length 0 at byte 2 is too small/); - }); - - it("stops on a segment length of 1", () => { - const scan = extractExifFromJpeg( - bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS), - ); - expect(scan.error).toMatch(/segment length 1 at byte 2 is too small/); - }); - - it("reports a segment length that runs past the end of the file", () => { + it.each([ + [ + "a file that is not an image", + new TextEncoder().encode("just some text, not an image"), + ], + [ + "a PNG without EXIF", + bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + ], + ["a JPEG without EXIF", bytes(SOI, APP0, SOS)], + // A length under 8 cannot hold the six-byte "Exif\0\0" header. This one + // has length 7 and holds only "Exif\0", so a read past its end would + // take the next segment's bytes as EXIF. + [ + "an APP1 segment too short to hold the Exif header", + bytes(SOI, app1(EXIF_HEADER.slice(0, 5)), SOS), + ], + [ + "an APP1 segment holding just the Exif header", + bytes(SOI, app1(EXIF_HEADER), SOS), + ], + [ + "a JPEG truncated inside a segment header", + bytes(SOI, [0xff, 0xe1, 0x00]), + ], + ["a JPEG that ends before the image data", bytes(SOI, APP0)], + // A length of 0 would step a scan by 2 bytes at a time through the + // rest of the file, reading garbage as markers. + [ + "a zero-length segment", + bytes( + SOI, + ZERO_LENGTH_APP0, + ZERO_LENGTH_APP0, + ZERO_LENGTH_APP0, + ZERO_LENGTH_APP0, + SOS, + ), + ], + ["a segment length of 1", bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS)], // APP1 claims 0x4000 bytes but only the "Exif\0\0" header follows. - const scan = extractExifFromJpeg( + [ + "a segment length that runs past the end of the file", bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER), - ); - expect(scan.exif).toBeUndefined(); - expect(scan.error).toMatch(/runs past the end of the file/); + ], + // "XX" where the TIFF byte order belongs. + [ + "an EXIF block that cannot be parsed", + bytes(SOI, app1([...EXIF_HEADER, 0x58, 0x58]), SOS), + ], + ])("returns no fields for %s", (_, input) => { + expect(readPhotoExif(input)).toStrictEqual({}); }); }); @@ -108,10 +186,30 @@ describe("extractImageMetadata", () => { bytes(SOI, app1([...EXIF_HEADER, ...TIFF_ORIENTATION_6]), SOS), ); expect(meta?.exifError).toBeUndefined(); - expect(meta?.exif).toMatchObject({ Image: { Orientation: 6 } }); + expect(meta?.exif).toMatchObject({ Orientation: { value: 6 } }); }); - it("returns nothing for a file that is not a JPEG", () => { + it("parses EXIF from a HEIC", () => { + const meta = extractImageMetadata(HEIC_WITH_EXIF); + expect(meta?.exifError).toBeUndefined(); + expect(meta?.exif).toMatchObject({ + Make: { value: ["Canon"] }, + Model: { value: ["EOS R5"] }, + DateTimeOriginal: { value: ["2021:07:15 14:30:00"] }, + Orientation: { value: 6 }, + GPSLatitudeRef: { value: ["N"] }, + }); + }); + + it("keys a tag exifreader has no name for by its number", () => { + const meta = extractImageMetadata( + bytes(SOI, app1([...EXIF_HEADER, ...TIFF_UNNAMED_TAG]), SOS), + ); + expect(meta?.exifError).toBeUndefined(); + expect(meta?.exif).toMatchObject({ "undefined-49152": { value: 7 } }); + }); + + it("returns nothing for a file that is not an image", () => { const text = new TextEncoder().encode("just some text, not an image"); expect(extractImageMetadata(text)).toBeUndefined(); }); @@ -121,14 +219,20 @@ describe("extractImageMetadata", () => { bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER), ); expect(meta?.exif).toBeUndefined(); - expect(meta?.exifError).toMatch(/runs past the end of the file/); + expect(meta?.exifError).toBe( + "no tag could be read from the EXIF block", + ); }); it("keeps the raw bytes and the reason when EXIF cannot be parsed", () => { - const data = [...EXIF_HEADER, 0x58, 0x58]; - const meta = extractImageMetadata(bytes(SOI, app1(data), SOS)); + // The raw bytes are the whole EXIF block as exifreader finds it: for a + // JPEG, the APP1 segment, marker and length included. + const segment = app1([...EXIF_HEADER, 0x58, 0x58]); + const meta = extractImageMetadata(bytes(SOI, segment, SOS)); expect(meta?.exif).toBeUndefined(); - expect(meta?.exifRaw).toBe(Buffer.from(data).toString("base64")); - expect(meta?.exifError).toEqual(expect.any(String)); + expect(meta?.exifRaw).toBe(Buffer.from(segment).toString("base64")); + expect(meta?.exifError).toBe( + "no tag could be read from the EXIF block", + ); }); }); diff --git a/test/exif-heic.ts b/test/exif-heic.ts new file mode 100644 index 0000000..ad3acc6 --- /dev/null +++ b/test/exif-heic.ts @@ -0,0 +1,27 @@ +/** + * `exif.heic`, beside this file: a real 64x64 HEIC whose EXIF holds the same + * values as the hand-built JPEG in `library/content-library.test.ts`, for the + * tests of `exif()` and `backup-metadata --exif`. + * + * It was made once, in a throwaway node:22-alpine container (Alpine 3.23.3), + * with libheif 1.23.0 and exiftool 13.55: + * + * apk add libheif-tools exiftool imagemagick + * magick -size 64x64 gradient:red-blue -depth 8 in.png + * heif-enc -q 30 -o exif.heic in.png + * exiftool -overwrite_original \ + * -Make=Canon -Model="EOS R5" -LensModel="RF50mm F1.8 STM" \ + * -DateTimeOriginal="2021:07:15 14:30:00" -OffsetTimeOriginal="+02:00" \ + * -ExposureTime=1/250 -FNumber=2.8 -ISO=400 -FocalLength=50 \ + * -Orientation#=6 \ + * -GPSLatitude="40 26 46" -GPSLatitudeRef=N \ + * -GPSLongitude="79 58 56" -GPSLongitudeRef=W \ + * -GPSAltitude=12.5 -GPSAltitudeRef#=1 \ + * exif.heic + */ + +import { readFileSync } from "node:fs"; + +export const HEIC_WITH_EXIF = new Uint8Array( + readFileSync(new URL("exif.heic", import.meta.url)), +); diff --git a/test/exif.heic b/test/exif.heic new file mode 100644 index 0000000000000000000000000000000000000000..b6b460f1f9afbdd6a574ea76453aac111c22bbd7 GIT binary patch literal 933 zcmYjN&ubG=5T4mh(@<(!O|>>ije@aSD7$HjA*W!QLJzgjic;`k+$7{dHd)dj5ie;k zR;UmKMT9~Rf;Y{jhbs8%`Wnvs@r5HLmIKj`dtq}FGBMS8Qy_^ zM3_>lfss|swO>on^kc{aL7fBqgE5Dn-zapPo$`?=GR0-aiN;2V9dWpYT4UuO5}f0?b= zYR{A1xeG5VTC_hT#h>H|Wp1IqcZNUI+GjJE6tSx1TE!|iARnP%6Vi}caSA}OjPH1S zLy5rXMZ?Oma}vRrIIybe0kC~LpOfVWy=BbLW4A;h3i5!gh=TBIJm~UX@Ed$g{sS$L z(IP&_uI?#9Kj=jH^>ba$al|y8t5hqLU08}OP9f}DdqzD#sr|aPdB}Iy&wKqC%fct~ z0!Cso(bC=dLx_L2j&|R|!G~Cj)Uc-0qrPq*4sWs7r_&*No^ { await lib.close(); }); - it("returns no EXIF fields for an original that is not a JPEG", async () => { + // What exif() returns for HEIC_WITH_EXIF, which holds the same values as + // JPEG_WITH_EXIF. + const heicFields: PhotoExif = { + make: "Canon", + model: "EOS R5", + lensModel: "RF50mm F1.8 STM", + dateTimeOriginal: new Date(Date.UTC(2021, 6, 15, 14, 30)), + offsetTimeOriginal: "+02:00", + exposureTime: 1 / 250, + fNumber: 2.8, + iso: 400, + focalLength: 50, + orientation: 6, + gpsLatitude: 40 + 26 / 60 + 46 / 3600, + gpsLongitude: -(79 + 58 / 60 + 56 / 3600), + gpsAltitude: -12.5, + }; + + it("reads the same common EXIF fields from a HEIC original", async () => { + const lib = await open({ contentSource: stubSource(HEIC_WITH_EXIF) }); + expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual( + heicFields, + ); + await lib.close(); + }); + + it("reads the EXIF of a live photo whose image is a HEIC", async () => { + const { file: live, body } = await asLivePhoto( + file(1, 1), + livePhotoZip({ "image.heic": HEIC_WITH_EXIF, "video.mov": VIDEO }), + livePhotoHash(HEIC_WITH_EXIF, VIDEO), + ); + const lib = await open({ + client: new FilesClient([live]), + contentSource: cdnSource(new Map([[1, body]])), + }); + expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual( + heicFields, + ); + await lib.close(); + }); + + it("returns no EXIF fields for an original that is not an image", async () => { const lib = await open(); expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({}); await lib.close(); diff --git a/yarn.lock b/yarn.lock index 7759c2d..4386074 100644 --- a/yarn.lock +++ b/yarn.lock @@ -702,6 +702,11 @@ loupe "^3.1.2" tinyrainbow "^1.2.0" +"@xmldom/xmldom@^0.9.10": + version "0.9.12" + resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.9.12.tgz#1f84c07cb95ccf28202299f77b5fd7fc257151e8" + integrity sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A== + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -1002,10 +1007,12 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -exif-reader@2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/exif-reader/-/exif-reader-2.0.3.tgz#259997735080bc6bb959c37b32c60f004ec4391d" - integrity sha512-zFbQvguwT9JkqyYhR7pjE1Yn8SagwaGLNRU0Oh14xFa1paSf5Gzxn4gxgk0XhnudI0UIqU+HgnBX93+nva592A== +exifreader@4.46.0: + version "4.46.0" + resolved "https://registry.yarnpkg.com/exifreader/-/exifreader-4.46.0.tgz#b6216eae512997587c45114f972cc14ca979205f" + integrity sha512-ksHTpjXKWzbckY+bYlGaomG0EobHJkaMWLg5OPbzlTPda04F2dfrDfYSz8iAPp/kXYUSQdINBVI6nCAjQ8PQ/Q== + optionalDependencies: + "@xmldom/xmldom" "^0.9.10" expect-type@^1.1.0: version "1.3.0" From 10e1a9ef39802662206844674b2bd3fe280f4616 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 23:20:58 +0200 Subject: [PATCH 06/39] Save path ./photos/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.fileID.ext; download() from the cache first (closes #143) Originals are saved at `{downloadDirectory}/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.{fileID}{ext}`. The date is the photo's `takenAt` in local time, and `downloadDirectory` defaults to `./photos`, resolved when the library opens. The old `originals/` layout is gone. `photo.download()` writes the original to `savePath`. It copies from the cache when the cache holds the original, and fetches otherwise. `lib.backup()` uses the same path and rule, and every album in `collections/` links to it. `isLocal` is true only when the original is at `savePath`. For a file in several albums, one rule picks the copy everything uses: the most recently synced, with the lowest album ID breaking a tie. Model: opus-5-5 --- README.md | 157 +++++++------ TODO.md | 11 + src/backup.ts | 234 +++++++++---------- src/index.ts | 1 + src/library/content.ts | 218 +++++++++++------ src/library/index.ts | 69 +++--- src/library/read.ts | 74 ++++-- src/library/records.ts | 48 ++-- src/library/store.ts | 13 +- test/cli/backup.test.ts | 324 ++++++++++++++------------ test/cli/commands.test.ts | 2 +- test/library/content-eviction.test.ts | 1 + test/library/content-library.test.ts | 250 ++++++++++++++++++-- test/library/content.test.ts | 56 ++++- test/library/precache.test.ts | 2 + test/library/read.test.ts | 14 +- 16 files changed, 975 insertions(+), 499 deletions(-) diff --git a/README.md b/README.md index 80c573d..bb365bc 100644 --- a/README.md +++ b/README.md @@ -528,25 +528,36 @@ the smallest does not. ``` / - originals/ - . actual file content (one per unique file, - two for a live photo: see below) - .json the file's basic metadata fields quak - keeps, and its private and public magic - metadata - .livephoto.json which of a live photo's two files is which + YYYY/YYYY-MM/YYYY-MM-DD/ + YYYY-MM-DD.. actual file content, at its save path (one + per unique file, two for a live photo: see + below) + YYYY-MM-DD..json the file's basic metadata fields quak + keeps, and its private and public magic + metadata + YYYY-MM-DD..livephoto.json + which of a live photo's two files is which collections/ / - -> ../../originals/<fileID>.<ext> (symlink) - <name>.json collection metadata + file list - failures.json files that failed and have not yet succeeded + <title> -> ../../YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext> + (symlink) + <name>.json collection metadata + file list + failures.json files that failed and have not yet succeeded ``` +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 +`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 +name as uploaded, case kept, or `.bin` when it has none or it holds anything but +letters and digits. When the date or the time zone changes, the next run saves +the original at its new path and leaves the old copy where it is. + `failures.json` records each failed file with the kind of failure, how many times it has been tried and when it was last tried. A file leaves it once it succeeds, or once it is no longer in the library or in the backup's scope. The library's `lib.backup({ includeThumbnails: true })` also writes -`thumbnails/<fileID>.jpg` beside `originals/`; `quak backup` does not. +`thumbnails/<fileID>.jpg` beside `collections/`; `quak backup` does not. A collection's directory and JSON are named after the collection, and a symlink after the file's title, both with unsafe characters replaced. When two @@ -558,22 +569,21 @@ name stays the same from run to run until such a clash appears or goes away. A live photo, which Ente stores as one ZIP of its image and its video, is stored as those two files, which a photo viewer can open: each is -`originals/<fileID>.<ext>` with the extension it has inside the ZIP (for example -`12345.heic` and `12345.mov`), and `<fileID>.livephoto.json` names the two. The -live photo counts as stored only when both files are present and not empty. Its -album folder links both, each named after the title with that file's extension -(`IMG_0001.heic` and `IMG_0001.mov`). A live photo that an earlier version of -quak stored as the ZIP, under the image's name, is replaced by its two files on -the next run, and the ZIP and its link are removed. +`YYYY-MM-DD.<fileID>.<ext>` with the extension it has inside the ZIP (for +example `2026-03-01.12345.heic` and `2026-03-01.12345.mov`), and +`YYYY-MM-DD.<fileID>.livephoto.json` names the two. The live photo counts as +stored only when both files are present and not empty. Its album folder links +both, each named after the title with that file's extension (`IMG_0001.heic` and +`IMG_0001.mov`). -Each run removes the symlinks into `originals/` that no longer belong in their -collection's directory, and the directories (and JSON) of collections that were -deleted or renamed. Nothing else in `collections/` is touched: a file or a +Each run removes the symlinks into the date folders that no longer belong in +their collection's directory, and the directories (and JSON) of collections that +were deleted or renamed. Nothing else in `collections/` is touched: a file or a symlink you put there stays, and a directory that still holds one after its symlinks are removed stays too, with its JSON. Each file is downloaded exactly once regardless of how many collections it -appears in, and written once: straight into `originals/`, with no copy left in +appears in, and written once: straight to its save path, with no copy left in the cache. An original the cache already held is copied from there instead. On subsequent runs, existing originals are skipped. If a download fails, the error is logged and the backup continues with the next file. The exit code is non-zero @@ -586,14 +596,15 @@ 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 after a power cut. A downloaded original's temporary file is named `.quak-<pid>-<random>.tmp`, one copied from the cache -`.quak-backup-<fileID>.<ext>-<pid>-<random>.tmp`. A run that is killed can leave -one of these temporary files behind; the next backup deletes those whose process -is no longer running. The content cache uses the same scheme, and opening a -library deletes the temporary files in the cache whose process is no longer -running, so a download another process has in progress in the same cache is left -alone. The rename replaces whatever was at the destination rather than writing -through it: a symlink there is replaced, not followed, and the new file has the -temporary file's permissions, not those of the file it replaced. +`.quak-backup-YYYY-MM-DD.<fileID>.<ext>-<pid>-<random>.tmp`. A run that is +killed can leave one of these temporary files behind; the next backup deletes +those whose process is no longer running. The content cache uses the same +scheme, and opening a library deletes the temporary files in the cache whose +process is no longer running, so a download another process has in progress in +the same cache is left alone. The rename replaces whatever was at the +destination rather than writing through it: a symlink there is replaced, not +followed, and the new file has the temporary file's permissions, not those of +the file it replaced. ## TODO @@ -635,21 +646,24 @@ background, so an unreachable server does not block opening. `LibraryOptions`: -| Option | Default | Meaning | -| ------------------------ | -------------------------------------------------- | --------------------------------------------------------------------- | -| `client` | required | the account client (a `Client`, or any `LibraryClient`) | -| `cacheDirectory` | `quak/<userID>` under the per-user cache directory | where `metadata.json` and the content cache live | -| `downloadDirectory` | none | backup destination; an original already stored there counts as cached | -| `refreshIntervalSeconds` | `3` | background refresh cadence | -| `precacheThumbnails` | `true` | prefetch every thumbnail, newest first | -| `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals | -| `precacheOriginalsDays` | `7` | length in days of that latest window | -| `cacheOriginalsMaxBytes` | 100 GiB | size limit on the originals cache; pinned originals can exceed it | -| `freeBelowBytes` | 50 GiB | free space to protect on the volume; the effective limit adapts down | -| `isOriginalPinned` | none | extra predicate for originals that must never be evicted | -| `pools` | fresh `RequestPools` | the bounded request pools (sets concurrency) | -| `onProgress` | none | refresh/ML/precache progress callback (`RefreshEvent`) | -| `contentSource` | the client's own | override the byte source (mainly for tests) | +| Option | Default | Meaning | +| ------------------------ | -------------------------------------------------- | -------------------------------------------------------------------- | +| `client` | required | the account client (a `Client`, or any `LibraryClient`) | +| `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 | +| `refreshIntervalSeconds` | `3` | background refresh cadence | +| `precacheThumbnails` | `true` | prefetch every thumbnail, newest first | +| `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals | +| `precacheOriginalsDays` | `7` | length in days of that latest window | +| `cacheOriginalsMaxBytes` | 100 GiB | size limit on the originals cache; pinned originals can exceed it | +| `freeBelowBytes` | 50 GiB | free space to protect on the volume; the effective limit adapts down | +| `isOriginalPinned` | none | extra predicate for originals that must never be evicted | +| `pools` | fresh `RequestPools` | the bounded request pools (sets concurrency) | +| `onProgress` | none | refresh/ML/precache progress callback (`RefreshEvent`) | +| `contentSource` | the client's own | override the byte source (mainly for tests) | + +The default `downloadDirectory` is resolved against the working directory once, +when the library opens; `lib.downloadDirectory` holds the result. Concurrency is set through `pools`: construct `new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })` @@ -702,20 +716,26 @@ and `originalPath`, and `photo.year`, the local-time year of `takenAt`, all as synchronous getters read from RAM; `photo.record()` → `PhotoRecord`. Two more synchronous getters look at the disk and never touch the network: -- `photo.savePath` → `string | undefined` — where `lib.backup()` writes the - original, `originals/<fileID>.<ext>` under the `downloadDirectory` the library - was opened with, whether or not it is there yet: for a live photo already - stored, its image. For a live photo not yet stored, it carries the title's - extension, and the backup may store the image under a different one, found - inside the live photo. `undefined` when the library has no `downloadDirectory` - or no content source. -- `photo.isLocal` → `boolean` — whether the whole original is at `savePath`. +- `photo.savePath` → `string` — where `photo.download()` and `lib.backup()` put + the original, whether or not it is there yet: + `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>` under the library's + `downloadDirectory` (see Backup layout above for the date and the extension). + For a live photo already stored, its image. For a live photo not yet stored, + it carries the title's extension, and the image may be stored under a + different one, found inside the live photo. It needs no content source. +- `photo.isLocal` → `boolean` — whether the whole original is at `savePath`. A + copy only in the cache does not count. -Four async methods may download: +Five async methods may download: - `await photo.original(opts?)` → `{ path, bytes, videoPath? }` — the full-resolution file. For a live photo, `path` and `bytes` are its image's and `videoPath` is its video. +- `await photo.download()` → `{ path, bytes, videoPath? }`, as `original()` — + puts the original at `savePath`, creating its folders. When it is already + there, nothing is written. When the cache holds it, it is copied from the + cache; otherwise it is fetched straight to `savePath`, with no copy left in + the cache. Afterwards `isLocal` is true. - `await photo.thumbnail(opts?)` → `{ path, bytes }`. - `await photo.content(opts?)` → `Uint8Array` — the original's bytes, read through `original()`; for a live photo, its image's. @@ -731,10 +751,11 @@ Four async methods may download: They serve from the on-disk content cache when the bytes are present and otherwise fetch through the pools; `original()`, `content()` and `exif()` also -serve an original the backup has already stored. `opts.onProgress` reports +serve an original already stored at its save path. `opts.onProgress` reports per-file progress. They throw when the library was opened without a content -source. An original that `content()` or `exif()` downloads lands in the cache, -which does not make `isLocal` true; only `lib.backup()` does. +source. An original that `original()`, `content()` or `exif()` downloads lands +in the cache, which does not make `isLocal` true; only `download()` and +`lib.backup()` do. Lower-level accessors that return decrypted model objects (which hold key material) are also available: `listCollections()`, `getCollection(id)`, @@ -777,15 +798,15 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange` default limit 20). quak bundles no text encoder, so `searchByEmbedding` takes a query vector the caller produced elsewhere. - `await lib.backup(opts?)` → `BackupResult`. It waits for a refresh as - `fresh()` does, fetches every in-scope original not already in the backup - (and, with `includeThumbnails`, thumbnails) through the content cache, and - rebuilds the on-disk backup tree with a durable failure ledger. A fetched - original is written straight into the backup's `originals/` and not into the - cache, which then counts it as present; one the cache already held is copied - from there. `BackupOptions`: `downloadDirectory` (falls back to the one - `open()` was given), `includeOriginals` (default `true`), `includeThumbnails` - (default `false`), `onlyAlbumNames`, and `onProgress`. See Backup layout above - for the tree it writes. + `fresh()` does, puts every in-scope original not already at its save path + there as `photo.download()` does (and, with `includeThumbnails`, fetches + thumbnails) through the content cache, and rebuilds the on-disk backup tree + with a durable failure ledger. A fetched original is written straight to its + save path and not into the cache, which then counts it as present; one the + cache already held is copied from there. `BackupOptions`: `downloadDirectory` + (falls back to the library's), `includeOriginals` (default `true`), + `includeThumbnails` (default `false`), `onlyAlbumNames`, and `onProgress`. See + Backup layout above for the tree it writes. ### Request pools @@ -816,7 +837,7 @@ When `metadata.json` belongs to a different account than the client's, originals and thumbnails are kept; they are reached only through the files the current account's records name. -A live photo's original is cached as in the backup: its image and its video, +A live photo's original is cached as at its save path: its image and its video, each `originals/<fileID>.<ext>` with its own extension, and `originals/<fileID>.livephoto.json` naming them; the two are evicted together. A live photo that an earlier version cached as its ZIP is not served: the library @@ -840,7 +861,7 @@ from a very old client, is stored unchecked. - `src/library/index.ts`: `Library`, `LibraryOptions`, `LibraryStatus`, `LibraryClient`, `RefreshEvent` - `src/library/read.ts`: `Album`, `Photo`, `AlbumsAPI`, `PhotosAPI`, - `TimelineAPI`, `PhotoFilter`, `TimelineGroup`, `GroupBy` + `TimelineAPI`, `PhotoFilter`, `TimelineGroup`, `GroupBy`, `SavePathLookup` - `src/library/content.ts`: `ContentResult`, `ContentOptions`, `ThumbnailsAPI`, `EnsureOptions`, `EnsureResult`, `ContentSource` - `src/library/records.ts`: `PhotoRecord`, `AlbumRecord`, `LibrarySnapshot`, diff --git a/TODO.md b/TODO.md index b342066..acc99ef 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,17 @@ declares one. # Completed Steps +- 2026-10-01: Each original's save path is + `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>` under the library's + download directory, which defaults to `photos` in the working directory (issue + 143). The date is the photo's `takenAt` in the machine's time zone. + `photo.savePath` is always a string, with or without a content cache, and + `isLocal` is true only when the original is at its save path. + `photo.download()` puts the original there, copied from the cache when the + cache holds it and fetched otherwise. `lib.backup()` does the same for each + file, writes each file's JSON beside its original, and links `collections/` to + the save paths. + - 2026-10-01: `photo.exif()` and `backup-metadata --exif` read EXIF from HEIC/HEIF originals, a live photo's HEIC image included, as well as JPEG and the other image formats `exifreader` reads (issue 145). `exifreader` replaces diff --git a/src/backup.ts b/src/backup.ts index 9ea9707..23b92c1 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -2,29 +2,30 @@ // // `lib.backup()` waits for a completed refresh of the library (a failed one // fails the backup before any file is touched), then, for every file in scope, -// gets its original bytes onto disk under `downloadDirectory` and rebuilds the -// derived views (per-file sidecars, per-collection symlink trees, -// per-collection JSON) from the model. The on-disk layout is the historical -// one: +// puts its original at its save path under `downloadDirectory`, as +// `Photo.download()` does, and rebuilds the derived views (per-file sidecars, +// per-collection symlink trees, per-collection JSON) from the model. The +// on-disk layout: // // <downloadDirectory>/ -// originals/<fileID>.<ext> the decrypted bytes -// originals/<fileID>.json per-file metadata sidecar -// collections/<name>/<title> symlink into ../../originals -// collections/<name>.json per-collection metadata -// failures.json durable ledger of unresolved failures +// YYYY/YYYY-MM/YYYY-MM-DD/ +// YYYY-MM-DD.<fileID>.<ext> the decrypted bytes (the save path) +// YYYY-MM-DD.<fileID>.json per-file metadata sidecar +// collections/<name>/<title> symlink to the original +// collections/<name>.json per-collection metadata +// failures.json durable ledger of unresolved failures // -// A live photo's original is its image and its video, `<fileID>.<ext>` each -// with its own extension, and `originals/<fileID>.livephoto.json` naming them; -// its album folders link both. +// A live photo's original is its image and its video, each with its own +// extension, beside `YYYY-MM-DD.<fileID>.livephoto.json` naming them; its album +// folders link both. // // Crash-safety rests on two properties. Bytes are present-means-complete: an -// original appears under `originals/` only via the content layer's atomic +// original appears at its save path only via the content layer's atomic // temp-then-rename, so a file that exists is whole and is never re-fetched — an -// interrupted run resumes by listing the directory. The derived views hold no -// unique state, so they are rebuilt every run; that repairs stale sidecars and -// missing or broken symlinks left by an earlier crash. A rebuild also removes -// the symlinks into originals/ that no longer belong to an album, and the +// interrupted run resumes by looking at the save paths. The derived views hold +// no unique state, so they are rebuilt every run; that repairs stale sidecars +// and missing or broken symlinks left by an earlier crash. A rebuild also +// removes the symlinks to originals that no longer belong to an album, and the // directories of albums that no longer exist. // // Resilience (issue #8): no per-file condition aborts the run. A failed @@ -48,24 +49,25 @@ import { symlinkSync, writeFileSync, } from "node:fs"; -import { copyFile, rename, rm } from "node:fs/promises"; -import { basename, dirname, extname, join, relative } from "node:path"; +import { dirname, extname, join, relative, resolve } from "node:path"; -import { fsyncPath, removeLeftoverTempFiles } from "./download/index.js"; +import { removeLeftoverTempFiles } from "./download/index.js"; import { sanitizeFileName, withExtension } from "./filename.js"; import { - nameInOriginals, - storedOriginal, - writeLivePhotoJSON, + copyAtomic, + placeOriginal, + savePath, + storedAtSavePath, } from "./library/content.js"; +import { representative } from "./library/records.js"; import type { Collection, EnteFile } from "./model/types.js"; export type ProgressCallback = (message: string) => void; export interface BackupOptions { - // Where the backup tree lives. Required: with none, `backup()` throws - // before any network traffic. A library opened with a `downloadDirectory` - // supplies the default. + // Where the backup tree lives. `lib.backup()` defaults it to the library's + // download directory; `runBackup` with none throws before any network + // traffic. downloadDirectory?: string; // Fetch and store full-resolution originals. Default true. includeOriginals?: boolean; @@ -89,7 +91,7 @@ export interface BackupResult { totalFiles: number; // Originals fetched (or copied from the cache) this run. downloaded: number; - // Originals already present and left untouched. + // Originals already at their save path and left untouched. skipped: number; // Files with an unresolved failure after this run (the ledger size); the // CLI exits non-zero while this is above zero. A file can be both @@ -107,8 +109,8 @@ export interface BackupLibrary { listFiles(collectionID: number): EnteFile[]; // Get an original's bytes onto disk through the content cache/pools, // returning where they landed: `destination` when they were fetched now, - // otherwise wherever they already were (the cache, or a prior backup). A - // live photo lands as its image and its video, fetched now beside + // otherwise wherever they already were (the cache, or the library's save + // path). A live photo lands as its image and its video, fetched now beside // `destination`. original( fileID: number, @@ -168,58 +170,6 @@ const classify = (err: unknown): FailureClass => { const errorMessage = (err: unknown): string => err instanceof Error ? err.message : String(err); -// 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 -// download writer, the temp file is fsynced before the rename and the directory -// after it, so a power cut cannot leave a correctly named but short original. -// The temp name carries this process's ID so a later run can tell a leftover -// from a copy still in progress (see `removeLeftoverTempFiles`). -const copyAtomic = async (src: string, dest: string): Promise<void> => { - if (src === dest) return; - const tmp = join( - dirname(dest), - `.quak-backup-${basename(dest)}-${process.pid}-${Math.random() - .toString(36) - .slice(2)}.tmp`, - ); - try { - await copyFile(src, tmp); - await fsyncPath(tmp); - // `rename` replaces the destination's directory entry: an existing - // symlink at `dest` is replaced, not followed, and the new file has - // the temp file's permissions (copied from `src`). - await rename(tmp, dest); - await fsyncPath(dirname(dest)); - } finally { - await rm(tmp, { force: true }); - } -}; - -// Put an original the library returned at `dest` in originals/, where a fresh -// fetch already wrote it. A live photo's image and video go beside `dest`: when -// they came from the cache they are copied, after removing whatever was at -// `dest` (an earlier version's ZIP of the two). Then the JSON file naming them -// is written, which is what makes the live photo count as stored. -const placeOriginal = async ( - file: EnteFile, - dest: string, - got: { path: string; videoPath?: string }, -): Promise<void> => { - if (got.videoPath === undefined) { - await copyAtomic(got.path, dest); - return; - } - const originalsDir = dirname(dest); - const path = join(originalsDir, basename(got.path)); - const videoPath = join(originalsDir, basename(got.videoPath)); - if (got.path !== path) { - await rm(dest, { force: true }); - await copyAtomic(got.path, path); - await copyAtomic(got.videoPath, videoPath); - } - await writeLivePhotoJSON(originalsDir, file.id, { path, videoPath }); -}; - // Ensure `linkPath` is a symlink to `target`, rebuilding a missing, wrong, or // non-symlink entry. Throws on failure (a directory in the way, no permission) // so the caller records it and moves on rather than aborting the run. @@ -291,36 +241,55 @@ const linksFor = ( })); }; -// Remove the symlinks in the album directory `dir` that point into -// `originalsDir` and are not named in `keep`. Nothing else in the directory -// is touched: anything else there was put there by the user. +// Every date folder (`YYYY/YYYY-MM/YYYY-MM-DD/`) under `root`, whether or not a +// file in this backup is saved there. A folder that cannot be read is skipped. +const dateFolders = (root: string): string[] => { + const subfolders = (dir: string, name: RegExp): string[] => { + try { + return readdirSync(dir, { withFileTypes: true }) + .filter((e) => e.isDirectory() && name.test(e.name)) + .map((e) => join(dir, e.name)); + } catch { + return []; + } + }; + return subfolders(root, /^\d{4}$/) + .flatMap((year) => subfolders(year, /^\d{4}-\d\d$/)) + .flatMap((month) => subfolders(month, /^\d{4}-\d\d-\d\d$/)); +}; + +// Whether the entry at `path` is a symlink a backup to `root` made: one to an +// original in a `YYYY/YYYY-MM/YYYY-MM-DD/` folder of `root`. +const linksToOriginal = (path: string, root: string): boolean => { + if (!lstatSync(path).isSymbolicLink()) return false; + const target = relative(root, resolve(dirname(path), readlinkSync(path))); + return /^\d{4}\/\d{4}-\d\d\/\d{4}-\d\d-\d\d\/[^/]+$/.test(target); +}; + +// Remove the symlinks in the album directory `dir` that point to an original +// in `root` and are not named in `keep`. Nothing else in the directory is +// touched: anything else there was put there by the user. const removeStaleLinks = ( dir: string, keep: Set<string>, - originalsDir: string, + root: string, ): void => { - const target = relative(dir, originalsDir); for (const name of readdirSync(dir)) { if (keep.has(name)) continue; const path = join(dir, name); - if ( - lstatSync(path).isSymbolicLink() && - dirname(readlinkSync(path)) === target - ) { - rmSync(path); - } + if (linksToOriginal(path, root)) rmSync(path); } }; // Remove the directories under `collectionsDir` that an earlier run wrote for // an album that is gone or renamed: a directory not named in `current` with a // `<name>.json` beside it holding an album ID, which is what a run writes. Its -// symlinks into originals/ are removed; if that leaves it empty, it and its -// JSON are deleted, otherwise both stay for what the user put there. +// symlinks to originals in `root` are removed; if that leaves it empty, it and +// its JSON are deleted, otherwise both stay for what the user put there. const removeStaleAlbumDirs = ( collectionsDir: string, current: Set<string>, - originalsDir: string, + root: string, ): void => { for (const entry of readdirSync(collectionsDir, { withFileTypes: true })) { if (!entry.isDirectory() || current.has(entry.name)) continue; @@ -334,7 +303,7 @@ const removeStaleAlbumDirs = ( continue; } const dir = join(collectionsDir, entry.name); - removeStaleLinks(dir, new Set(), originalsDir); + removeStaleLinks(dir, new Set(), root); if (readdirSync(dir).length > 0) continue; rmdirSync(dir); rmSync(jsonPath); @@ -402,34 +371,48 @@ export const runBackup = async ( log("Refreshing library..."); await lib.refresh(); - const originalsDir = join(downloadDirectory, "originals"); const collectionsDir = join(downloadDirectory, "collections"); const thumbnailsDir = join(downloadDirectory, "thumbnails"); - mkdirSync(originalsDir, { recursive: true }); mkdirSync(collectionsDir, { recursive: true }); if (includeThumbnails) mkdirSync(thumbnailsDir, { recursive: true }); - removeLeftoverTempFiles(originalsDir); removeLeftoverTempFiles(thumbnailsDir); + for (const dir of dateFolders(downloadDirectory)) { + removeLeftoverTempFiles(dir); + } const ledgerPath = join(downloadDirectory, "failures.json"); const ledger = loadLedger(ledgerPath); const now = Date.now(); // Collections in scope, and the distinct files across them (a file shared - // by two albums is one original). + // by two albums is one original). Each file is the membership + // `representative` picks from all of its albums, in scope or not, so it is + // saved at the path `photo.savePath` names. const allCollections = lib.listCollections(); const collections = allCollections.filter((c) => only ? only.has(c.name) : true, ); const collectionName = new Map<number, string>(); - for (const c of collections) collectionName.set(c.id, c.name); + for (const c of allCollections) collectionName.set(c.id, c.name); - const distinct = new Map<number, EnteFile>(); + const memberships = new Map<number, EnteFile[]>(); const filesByCollection = new Map<number, EnteFile[]>(); - for (const c of collections) { + for (const c of allCollections) { const files = lib.listFiles(c.id); filesByCollection.set(c.id, files); - for (const f of files) if (!distinct.has(f.id)) distinct.set(f.id, f); + for (const f of files) { + const arr = memberships.get(f.id); + if (arr) arr.push(f); + else memberships.set(f.id, [f]); + } + } + const distinct = new Map<number, EnteFile>(); + for (const c of collections) { + for (const f of filesByCollection.get(c.id)!) { + if (!distinct.has(f.id)) { + distinct.set(f.id, representative(memberships.get(f.id)!)); + } + } } const errors: BackupError[] = []; @@ -465,25 +448,22 @@ export const runBackup = async ( failedThisRun.add(file.id); }; - // Phase 1: get the bytes. Fetch each pending original (and optional - // thumbnail) through the content cache/pools and place it under the backup - // tree; a present file is left as is. + // Phase 1: get the bytes. Put each pending original at its save path + // through the content cache/pools, as `Photo.download()` does, and fetch + // the optional thumbnails; a present file is left as is. if (includeOriginals) { for (const [fileID, file] of distinct) { - if (storedOriginal(originalsDir, file) !== undefined) { + if (storedAtSavePath(downloadDirectory, file) !== undefined) { skipped++; continue; } - const dest = join(originalsDir, nameInOriginals(file)); try { log(`Fetching original ${file.metadata.title} (${fileID})...`); - // A fetched original is written straight to `dest` (a live - // photo beside it); only one that was already cached elsewhere - // is copied. - await placeOriginal( - file, - dest, - await lib.original(fileID, dest), + // A fetched original is written straight to its save path (a + // live photo beside it); only one that was already cached + // elsewhere is copied. + await placeOriginal(downloadDirectory, file, (dest) => + lib.original(fileID, dest), ); downloaded++; } catch (err) { @@ -519,9 +499,10 @@ export const runBackup = async ( // Phase 2: rebuild the derived views from the model. Sidecars first, for // every present original (this repairs stale ones). if (includeOriginals) { - for (const [fileID, file] of distinct) { - if (storedOriginal(originalsDir, file) !== undefined) { - writeSidecar(join(originalsDir, `${fileID}.json`), file); + for (const file of distinct.values()) { + if (storedAtSavePath(downloadDirectory, file) !== undefined) { + const path = savePath(downloadDirectory, file); + writeSidecar(withExtension(path, ".json"), file); } } } @@ -543,7 +524,11 @@ export const runBackup = async ( allCollections.map((c, i) => [c.id, dirNames[i]!]), ); try { - removeStaleAlbumDirs(collectionsDir, new Set(dirNames), originalsDir); + removeStaleAlbumDirs( + collectionsDir, + new Set(dirNames), + downloadDirectory, + ); } catch (err) { log(`FAILED removing old album directories: ${errorMessage(err)}`); } @@ -553,13 +538,18 @@ export const runBackup = async ( const colDir = join(collectionsDir, colDirName); mkdirSync(colDir, { recursive: true }); + // Every album links the one original, saved from the file's entry in + // `distinct`. const files = filesByCollection.get(c.id) ?? []; const links = files.flatMap((f) => - linksFor(f, storedOriginal(originalsDir, f)), + linksFor( + f, + storedAtSavePath(downloadDirectory, distinct.get(f.id)!), + ), ); const linkNames = uniqueNames(links, true); try { - removeStaleLinks(colDir, new Set(linkNames), originalsDir); + removeStaleLinks(colDir, new Set(linkNames), downloadDirectory); } catch (err) { log(`FAILED removing old links in ${c.name}: ${errorMessage(err)}`); } diff --git a/src/index.ts b/src/index.ts index b5ebfe1..d237d91 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,6 +53,7 @@ export { type PhotoFilter, type TimelineGroup, type GroupBy, + type SavePathLookup, type ContentSource, type ContentResult, type ContentEvent, diff --git a/src/library/content.ts b/src/library/content.ts index fea3b74..396fdb8 100644 --- a/src/library/content.ts +++ b/src/library/content.ts @@ -23,6 +23,11 @@ // original with no recorded hash is stored unchecked, as the upstream client // does; thumbnails have none. On top of that this module refuses to record a // stored file that came out empty. +// +// It also names each original's save path under the download directory +// (`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 +// fetched again. import { closeSync, @@ -34,8 +39,10 @@ import { } from "node:fs"; import { chmod, + copyFile, mkdir, readdir, + rename, rm, stat, statfs, @@ -47,13 +54,15 @@ import type { ApiClient } from "../api/client.js"; import { downloadFile, downloadThumbnail, + fsyncPath, type ProgressCallback, removeLeftoverTempFiles, writeAtomic, } from "../download/index.js"; -import { safeExtension } from "../filename.js"; +import { safeExtension, withExtension } from "../filename.js"; import type { EnteFile } from "../model/types.js"; import type { Priority, RequestPools } from "./pools.js"; +import { takenAtOf } from "./records.js"; const DIR_MODE = 0o700; const FILE_MODE = 0o600; @@ -108,11 +117,9 @@ export interface ContentOptions { export interface PhotoContent { original(fileID: number, opts?: ContentOptions): Promise<ContentResult>; thumbnail(fileID: number, opts?: ContentOptions): Promise<ContentResult>; - // Where a backup stores the original, whether or not it is there yet. For - // a live photo not yet stored, it carries the title's extension, and the - // backup may store the image under a different one. - savePath(fileID: number): string | undefined; - isLocal(fileID: number): boolean; + // Put the original at the save path of `file`, the copy the `Photo` holds, + // and return it there. + download(file: EnteFile): Promise<ContentResult>; } export interface EnsureResult { @@ -199,10 +206,10 @@ export interface ContentCacheOptions { pools: RequestPools; source: ContentSource; cacheDirectory: string; - // The backup destination (issue-level `downloadDirectory`). An original - // already stored there by a backup counts as present, so the cache serves - // it rather than fetching a second copy. - downloadDirectory?: string; + // The root of the save paths. An original already stored at its save path + // counts as present, so the cache serves it rather than fetching a second + // copy. + downloadDirectory: string; // Resolve any membership of a file; every membership shares the underlying // content key, so any one decrypts the same bytes. getFile: (fileID: number) => EnteFile | undefined; @@ -229,11 +236,27 @@ class AbortDrop extends Error { } } -// The name of a file's original in originals/: `<fileID><ext>`, the extension -// taken from the title (or `.bin`). A backup names its originals the same way. +// The name of a file's original in the cache's originals/: `<fileID><ext>`, the +// extension taken from the title (or `.bin`). export const nameInOriginals = (file: EnteFile): string => `${file.id}${safeExtension(file.metadata.title)}`; +const pad = (n: number): string => String(n).padStart(2, "0"); + +// Where the original of `file` is saved under `root`: +// `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID><ext>`, dated by the photo's +// `takenAt` in this machine's time zone, with the extension taken from the +// title (or `.bin`). A live photo is stored as its image and its video beside +// this path, each with the extension found inside the live photo. +export const savePath = (root: string, file: EnteFile): string => { + const taken = new Date(takenAtOf(file)); + const year = String(taken.getFullYear()); + const month = `${year}-${pad(taken.getMonth() + 1)}`; + const day = `${month}-${pad(taken.getDate())}`; + const ext = safeExtension(file.metadata.title); + return join(root, year, month, day, `${day}.${file.id}${ext}`); +}; + // The fileID a cache filename encodes, or undefined when the name is not one // the cache writes (`<digits><ext>`). const fileIDFromName = (name: string): number | undefined => { @@ -279,28 +302,29 @@ const isZip = (path: string): boolean => { // A live photo's image and video are named with the extensions from inside its // ZIP, so their names alone do not say which is which. Wherever the cache or a -// backup stores one, a JSON file of this name beside them names both. -const livePhotoJSONName = (fileID: number): string => - `${fileID}.livephoto.json`; +// save path stores one, a JSON file of this name beside them names both. +// `name` is the original's name without its extension: `<fileID>` in the +// cache, `YYYY-MM-DD.<fileID>` at the save path. +const livePhotoJSONName = (name: string): string => `${name}.livephoto.json`; // The image and video that the live photo's JSON file in `dir` names, or -// undefined when there is none. Only names of the form the cache writes are -// taken, so the file cannot point outside `dir`. +// undefined when there is none. Only `name` with an extension is taken, so the +// file cannot point outside `dir`. const readLivePhotoJSON = ( dir: string, - fileID: number, + name: string, ): { path: string; videoPath: string } | undefined => { - const valid = (name: unknown): name is string => - typeof name === "string" && name === `${fileID}${safeExtension(name)}`; + const valid = (part: unknown): part is string => + typeof part === "string" && part === `${name}${safeExtension(part)}`; try { const { image, video } = JSON.parse( - readFileSync(join(dir, livePhotoJSONName(fileID)), "utf-8"), + readFileSync(join(dir, livePhotoJSONName(name)), "utf-8"), ); if (valid(image) && valid(video)) { return { path: join(dir, image), videoPath: join(dir, video) }; } } catch { - // No such file, or not one the cache wrote. + // No such file, or not one quak wrote. } return undefined; }; @@ -308,11 +332,11 @@ const readLivePhotoJSON = ( // Write the JSON file naming a live photo's image and video, both in `dir`. export const writeLivePhotoJSON = ( dir: string, - fileID: number, + name: string, stored: { path: string; videoPath: string }, ): Promise<void> => writeAtomic( - join(dir, livePhotoJSONName(fileID)), + join(dir, livePhotoJSONName(name)), new TextEncoder().encode( JSON.stringify({ image: basename(stored.path), @@ -321,17 +345,19 @@ export const writeLivePhotoJSON = ( ), ); -// The original of `file` as the cache or a backup stored it in `dir`, when all -// of it is there: `<fileID><ext>`, or a live photo's image and video. +// The original of `file` as stored in `dir` under `name` (without its +// extension), when all of it is there: `<name><ext>`, or a live photo's image +// and video. export const storedOriginal = ( dir: string, + name: string, file: EnteFile, ): { path: string; videoPath?: string } | undefined => { if (file.metadata.fileType !== "livePhoto") { - const path = join(dir, nameInOriginals(file)); + const path = join(dir, `${name}${safeExtension(file.metadata.title)}`); return hasContent(path) ? { path } : undefined; } - const stored = readLivePhotoJSON(dir, file.id); + const stored = readLivePhotoJSON(dir, name); return stored !== undefined && hasContent(stored.path) && hasContent(stored.videoPath) @@ -339,10 +365,76 @@ export const storedOriginal = ( : undefined; }; +// The original of `file` as stored at its save path under `root`, when all of +// it is there. +export const storedAtSavePath = ( + root: string, + file: EnteFile, +): { path: string; videoPath?: string } | undefined => { + const path = savePath(root, file); + return storedOriginal(dirname(path), basename(path, extname(path)), file); +}; + +// 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 +// download writer, the temp file is fsynced before the rename and the directory +// after it, so a power cut cannot leave a correctly named but short original. +// The temp name carries this process's ID so a later run can tell a leftover +// from a copy still in progress (see `removeLeftoverTempFiles`). +export const copyAtomic = async (src: string, dest: string): Promise<void> => { + if (src === dest) return; + const tmp = join( + dirname(dest), + `.quak-backup-${basename(dest)}-${process.pid}-${Math.random() + .toString(36) + .slice(2)}.tmp`, + ); + try { + await copyFile(src, tmp); + await fsyncPath(tmp); + // `rename` replaces the destination's directory entry: an existing + // symlink at `dest` is replaced, not followed, and the new file has + // the temp file's permissions (copied from `src`). + await rename(tmp, dest); + await fsyncPath(dirname(dest)); + } finally { + await rm(tmp, { force: true }); + } +}; + +// Put the original of `file` at its save path under `root`, creating its +// folders. `get` is given the save path and returns where the original is: a +// fetch writes it there, and a copy the cache holds is copied there. A live +// photo's image and video go beside the save path, each with its own +// extension: when they came from the cache they are copied. Then the JSON file +// naming them is written, which is what makes the live photo count as stored. +export const placeOriginal = async ( + root: string, + file: EnteFile, + get: (dest: string) => Promise<{ path: string; videoPath?: string }>, +): Promise<{ path: string; videoPath?: string }> => { + const dest = savePath(root, file); + await mkdir(dirname(dest), { recursive: true }); + const got = await get(dest); + if (got.videoPath === undefined) { + await copyAtomic(got.path, dest); + return { path: dest }; + } + const path = withExtension(dest, extname(got.path)); + const videoPath = withExtension(dest, extname(got.videoPath)); + if (got.path !== path) { + await copyAtomic(got.path, path); + await copyAtomic(got.videoPath, videoPath); + } + const name = basename(dest, extname(dest)); + await writeLivePhotoJSON(dirname(dest), name, { path, videoPath }); + return { path, videoPath }; +}; + export class ContentCache implements PhotoContent, ThumbnailsAPI { private readonly pools: RequestPools; private readonly source: ContentSource; - private readonly downloadDirectory?: string; + private readonly downloadDirectory: string; private readonly getFile: (fileID: number) => EnteFile | undefined; private readonly originalsDir: string; private readonly thumbnailsDir: string; @@ -440,32 +532,23 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI { return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress); } - // Where a backup to the download directory stores the file's original, - // whether or not it is there yet: for a live photo already stored, its - // image. For a live photo not yet stored, it carries the title's - // extension, and the backup may store the image under a different one. - // Undefined with no download directory. - savePath(fileID: number): string | undefined { - const file = this.getFile(fileID); - if (this.downloadDirectory === undefined || file === undefined) - return undefined; - const dir = join(this.downloadDirectory, "originals"); - return ( - storedOriginal(dir, file)?.path ?? join(dir, nameInOriginals(file)) - ); + // Put the original at the save path of `file` under the download directory + // and return it there. `file` is the copy the `Photo` holds, so the path is + // the one its `savePath` names, even after a refresh changed the date. One + // already stored there is returned as it is; one the cache holds is copied + // from it; any other is fetched straight to the save path, with no copy + // left in the cache. + async download(file: EnteFile): Promise<ContentResult> { + const root = this.downloadDirectory; + const saved = + storedAtSavePath(root, file) ?? + (await placeOriginal(root, file, (dest) => + this.backupOriginal(file.id, dest), + )); + return { ...saved, bytes: fileSize(saved.path) ?? 0 }; } - // Whether the whole original is in the download directory, as a backup - // stores it. A copy only in the cache does not count. - isLocal(fileID: number): boolean { - const file = this.getFile(fileID); - if (this.downloadDirectory === undefined || file === undefined) - return false; - const dir = join(this.downloadDirectory, "originals"); - return storedOriginal(dir, file) !== undefined; - } - - // Get an original for a backup. One not present anywhere is written + // Get an original for a save path. One not present anywhere is written // straight to `destination` and recorded there, so no second copy lands // in the cache; one already present is returned where it is. async backupOriginal( @@ -635,12 +718,9 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI { known.delete(fileID); } - // An original a backup already stored counts as present. - if (kind === "original" && this.downloadDirectory !== undefined) { - const stored = storedOriginal( - join(this.downloadDirectory, "originals"), - file, - ); + // An original already stored at its save path counts as present. + if (kind === "original") { + const stored = storedAtSavePath(this.downloadDirectory, file); if (stored !== undefined) { this.originals.set(fileID, stored); return { @@ -676,7 +756,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI { ? this.beginOriginalWrite(fileID) : null; try { - const stored = await this.download( + const stored = await this.fetchInto( file, dest, kind, @@ -691,12 +771,12 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI { ); } } - // A backup records its own live photos. + // `placeOriginal` records a live photo it saves. if ( stored.videoPath !== undefined && opts?.destination === undefined ) { - await writeLivePhotoJSON(dir, fileID, { + await writeLivePhotoJSON(dir, String(fileID), { path: stored.path, videoPath: stored.videoPath, }); @@ -719,7 +799,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI { // Fetch into `destination`, returning where the bytes landed: there, or // for a live photo, its image and video beside it. - private async download( + private async fetchInto( file: EnteFile, destination: string, kind: Kind, @@ -744,10 +824,10 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI { await utimes(path, now, now).catch(() => undefined); } - // Every stored original that lives under `originalsDir` (a backup-directory - // hit recorded in the map is excluded), with its size and mtime; a live + // Every stored original that lives under `originalsDir` (a save-path hit + // recorded in the map is excluded), with its size and mtime; a live // photo's size includes its video. Entries whose file has vanished are - // dropped from the map. Backups and thumbnails are never counted. + // dropped from the map. Save paths and thumbnails are never counted. private async measureOriginals(): Promise<{ entries: { fileID: number; @@ -853,7 +933,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI { await rm( join( this.originalsDir, - livePhotoJSONName(e.fileID), + livePhotoJSONName(String(e.fileID)), ), { force: true }, ); @@ -907,8 +987,8 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI { // earlier version stored under the image's name, and is removed. // Any other is left alone: another process may have just stored // it and not yet written the JSON file. - const livePhoto = names.has(livePhotoJSONName(id)) - ? readLivePhotoJSON(dir, id) + const livePhoto = names.has(livePhotoJSONName(String(id))) + ? readLivePhotoJSON(dir, String(id)) : undefined; if (livePhoto !== undefined) { into.set(id, livePhoto); diff --git a/src/library/index.ts b/src/library/index.ts index 92ddf33..8b7a98c 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -27,7 +27,7 @@ // never masked by a subsequent empty refresh. import { rm } from "node:fs/promises"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import envPaths from "env-paths"; import { MetadataStore } from "./store.js"; @@ -49,9 +49,12 @@ import { type PhotosAPI, type TimelineAPI, type FreshReads, + type SavePathLookup, } from "./read.js"; import { ContentCache, + savePath, + storedAtSavePath, type ContentSource, type ThumbnailsAPI, type EnsureOptions, @@ -70,6 +73,7 @@ export { type PhotoFilter, type TimelineGroup, type GroupBy, + type SavePathLookup, } from "./read.js"; export { type ContentSource, @@ -169,8 +173,10 @@ export interface LibraryOptions { // Where `metadata.json` lives. Defaults to the env-paths cache directory // plus the user id, so each account has its own cache. cacheDirectory?: string; - // Persistent backup destination. The refresh loop does not use it; the - // content cache treats an original already stored there as present. + // The root of every photo's save path, where `Photo.download()` and + // `lib.backup()` put originals. Defaults to `photos` in the working + // directory at open. The content cache treats an original already stored + // at its save path as present. downloadDirectory?: string; refreshIntervalSeconds?: number; onProgress?: RefreshProgressCallback; @@ -234,7 +240,7 @@ export interface LibraryStatus { export class Library { readonly cacheDirectory: string; - readonly downloadDirectory?: string; + readonly downloadDirectory: string; // The in-process read surface (issue #44). Each namespace answers // synchronously from the live record projection; no read touches the @@ -296,7 +302,7 @@ export class Library { store: MetadataStore; userID: number; cacheDirectory: string; - downloadDirectory?: string; + downloadDirectory: string; intervalMs: number; onProgress?: RefreshProgressCallback; pools: RequestPools; @@ -320,8 +326,14 @@ export class Library { // The read namespaces derive fresh from the store on each call, so they // always reflect the latest refresh. const derive = (): DerivedRecords => this.deriveNow(); - this.albums = makeAlbumsAPI(derive, this.cache); - this.photos = makePhotosAPI(derive, this.cache); + const root = this.downloadDirectory; + const saves: SavePathLookup = { + savePath: (file) => + storedAtSavePath(root, file)?.path ?? savePath(root, file), + isLocal: (file) => storedAtSavePath(root, file) !== undefined, + }; + this.albums = makeAlbumsAPI(derive, saves, this.cache); + this.photos = makePhotosAPI(derive, saves, this.cache); this.timeline = makeTimelineAPI(derive); this.thumbnails = { ensure: (opts: EnsureOptions): Promise<EnsureResult[]> => { @@ -350,6 +362,13 @@ export class Library { const { userID } = opts.client.whoami(); const cacheDirectory = opts.cacheDirectory ?? defaultCacheDirectory(userID); + if (opts.downloadDirectory === "") { + throw new Error( + "library: downloadDirectory is empty (leave it out to save " + + "under photos/ in the working directory)", + ); + } + const downloadDirectory = opts.downloadDirectory ?? resolve("photos"); const metadataPath = join(cacheDirectory, "metadata.json"); let store = await MetadataStore.load(metadataPath); // A cache directory given explicitly can hold another account's cache. @@ -404,7 +423,7 @@ export class Library { pools, source, cacheDirectory, - downloadDirectory: opts.downloadDirectory, + downloadDirectory, getFile: (fileID) => store.getFileByID(fileID), cacheOriginalsMaxBytes: opts.cacheOriginalsMaxBytes, freeBelowBytes: opts.freeBelowBytes, @@ -426,7 +445,7 @@ export class Library { store, userID, cacheDirectory, - downloadDirectory: opts.downloadDirectory, + downloadDirectory, intervalMs, onProgress: opts.onProgress, pools, @@ -470,9 +489,10 @@ export class Library { return this.store.getFile(collectionID, fileID); } - // Any membership of a file, addressed by file id alone. A file's own - // metadata (title, creationTime) is identical across the collections it - // belongs to, so this serves the point commands that hold only a fileID. + // The membership of a file its record is read from, addressed by file id + // alone. A file's own metadata (title, creationTime) is identical across + // the collections it belongs to, so this serves the point commands that + // hold only a fileID. getFileByID(fileID: number): EnteFile | undefined { return this.store.getFileByID(fileID); } @@ -543,27 +563,20 @@ export class Library { }; } - // Back up every in-scope file to `downloadDirectory` in the historical - // on-disk layout, with a durable failure ledger (issue #51). Waits for a - // completed refresh first, as `fresh()` does, joining one already running, - // and rejects before touching any file when it fails. Then fetches pending - // originals (and optional thumbnails) through the content cache and pools, - // and rebuilds the derived symlink/JSON views from the model. Throws before - // any network work when no download directory is available or no content - // cache backs the originals it must fetch. + // Back up every in-scope file to `opts.downloadDirectory`, or else the + // library's, each original at its save path, with a durable failure + // ledger (issue #51). Waits for a completed refresh first, as `fresh()` + // does, joining one already running, and rejects before touching any file + // when it fails. Then puts pending originals at their save paths as + // `Photo.download()` does (and optional thumbnails) through the content + // cache and pools, and rebuilds the derived symlink/JSON views from the + // model. Throws before any network work when no content cache backs the + // originals it must fetch. backup(opts?: BackupOptions): Promise<BackupResult> { const downloadDirectory = opts?.downloadDirectory ?? this.downloadDirectory; const includeOriginals = opts?.includeOriginals ?? true; const includeThumbnails = opts?.includeThumbnails ?? false; - if (!downloadDirectory) { - return Promise.reject( - new Error( - "backup requires a downloadDirectory (pass one to " + - "backup() or open the library with one)", - ), - ); - } if ((includeOriginals || includeThumbnails) && !this.cache) { return Promise.reject( new Error( diff --git a/src/library/read.ts b/src/library/read.ts index 68a8dd2..ae040cb 100644 --- a/src/library/read.ts +++ b/src/library/read.ts @@ -12,18 +12,26 @@ // plain records are the serializable surface, and `record()` returns one. // // A `Photo` also fetches its own bytes: `original()`, `thumbnail()`, -// `content()` and `exif()` go through the on-disk content cache (issue #46), -// and are the one place in this module that may touch the network. A library -// opened without a content source leaves that cache absent, and those methods -// then throw. `savePath` and `isLocal` look only at the disk. +// `download()`, `content()` and `exif()` go through the on-disk content cache +// (issue #46), and are the one place in this module that may touch the +// network. A library opened without a content source leaves that cache absent, +// and those methods then throw. `savePath` and `isLocal` look only at the disk +// and need no cache. import { readFile } from "node:fs/promises"; import { readPhotoExif, type PhotoExif } from "../exif.js"; -import type { CollectionType, FileType } from "../model/types.js"; +import type { CollectionType, EnteFile, FileType } from "../model/types.js"; import type { ContentOptions, ContentResult, PhotoContent } from "./content.js"; import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js"; +// Where a photo's original is saved, and whether all of it is there. The +// library answers both from the disk, with or without a content cache. +export interface SavePathLookup { + savePath(file: EnteFile): string; + isLocal(file: EnteFile): boolean; +} + // Newest first, with fileID as a stable tiebreak so equal-timed files order // deterministically — the same order the record projection uses. const byNewest = (a: PhotoRecord, b: PhotoRecord): number => @@ -35,10 +43,14 @@ const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number => b.updationTime - a.updationTime || b.collectionID - a.collectionID; // A single photo. Field access mirrors `PhotoRecord`; `record()` returns the -// underlying plain record for callers that need the IPC-safe value. +// underlying plain record for callers that need the IPC-safe value. `file` is +// the membership the record is read from, so the save path carries the date of +// `takenAt` and stays known after a refresh removes the file from the library. export class Photo { constructor( private readonly rec: PhotoRecord, + private readonly file: EnteFile, + private readonly saves: SavePathLookup, private readonly cache?: PhotoContent, ) {} @@ -89,20 +101,20 @@ export class Photo { return this.rec.isHidden; } - // Where `lib.backup()` stores the original in the library's download - // directory, whether or not it is there yet: for a live photo already - // stored, its image. For a live photo not yet stored, it carries the - // title's extension, and the backup may store the image under a different - // one. Undefined when the library has no download directory or no content - // cache. - get savePath(): string | undefined { - return this.cache?.savePath(this.rec.fileID); + // Where `download()` and `lib.backup()` put the original under the + // library's download directory, whether or not it is there yet: + // `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID><ext>`. For a live photo + // already stored, its image. For a live photo not yet stored, it carries + // the title's extension, and the image may be stored under a different + // one. + get savePath(): string { + return this.saves.savePath(this.file); } // Whether the whole original is at `savePath`. A copy only in the cache // does not count. get isLocal(): boolean { - return this.cache?.isLocal(this.rec.fileID) ?? false; + return this.saves.isLocal(this.file); } record(): PhotoRecord { @@ -111,12 +123,20 @@ export class Photo { // Fetch and cache the full-resolution original, returning its on-disk path // and byte length; for a live photo, its image's, and its video's path as - // `videoPath`. Served from the cache (or the backup download directory) - // when already present, otherwise fetched through the content pool. + // `videoPath`. Served from the cache (or the save path) when already + // present, otherwise fetched through the content pool. async original(opts?: ContentOptions): Promise<ContentResult> { return this.cacheOrThrow().original(this.rec.fileID, opts); } + // Put the original at `savePath` and return it there, as `original()` + // does. When it is already there, nothing is written. When the cache holds + // it, it is copied from there; otherwise it is fetched straight to + // `savePath`. + async download(): Promise<ContentResult> { + return this.cacheOrThrow().download(this.file); + } + // As `original`, for the thumbnail, through the thumbnail pool. async thumbnail(opts?: ContentOptions): Promise<ContentResult> { return this.cacheOrThrow().thumbnail(this.rec.fileID, opts); @@ -156,6 +176,7 @@ export class Album { constructor( private readonly rec: AlbumRecord, private readonly records: DerivedRecords, + private readonly saves: SavePathLookup, private readonly content?: PhotoContent, ) {} @@ -190,7 +211,10 @@ export class Album { const out: Photo[] = []; for (const id of this.rec.fileIDs) { const p = this.records.photos.get(id); - if (p) out.push(new Photo(p, this.content)); + const file = this.records.files.get(id); + if (p && file) { + out.push(new Photo(p, file, this.saves, this.content)); + } } return out; } @@ -253,18 +277,19 @@ export interface FreshReads { export const makeAlbumsAPI = ( derive: () => DerivedRecords, + saves: SavePathLookup, content?: PhotoContent, ): AlbumsAPI => ({ list: (): Album[] => { const records = derive(); return [...records.albums.values()] .sort(byNewestAlbum) - .map((rec) => new Album(rec, records, content)); + .map((rec) => new Album(rec, records, saves, content)); }, byID: ({ collectionID }): Album | undefined => { const records = derive(); const rec = records.albums.get(collectionID); - return rec ? new Album(rec, records, content) : undefined; + return rec ? new Album(rec, records, saves, content) : undefined; }, byName: ({ albumName }): Album | undefined => { const records = derive(); @@ -273,17 +298,20 @@ export const makeAlbumsAPI = ( const match = [...records.albums.values()] .sort(byNewestAlbum) .find((rec) => rec.name === albumName); - return match ? new Album(match, records, content) : undefined; + return match ? new Album(match, records, saves, content) : undefined; }, }); export const makePhotosAPI = ( derive: () => DerivedRecords, + saves: SavePathLookup, content?: PhotoContent, ): PhotosAPI => ({ byID: ({ fileID }): Photo | undefined => { - const rec = derive().photos.get(fileID); - return rec ? new Photo(rec, content) : undefined; + const records = derive(); + const rec = records.photos.get(fileID); + const file = records.files.get(fileID); + return rec && file ? new Photo(rec, file, saves, content) : undefined; }, records: ({ fileIDs }): PhotoRecord[] => { const { photos } = derive(); diff --git a/src/library/records.ts b/src/library/records.ts index 5136ddb..4a59e92 100644 --- a/src/library/records.ts +++ b/src/library/records.ts @@ -86,6 +86,10 @@ export interface LibraryChange { export interface DerivedRecords { albums: Map<number, AlbumRecord>; photos: Map<number, PhotoRecord>; + // The membership each photo's record is read from, for its `Photo`'s save + // path. It holds the file's key, so it stays in this process: no snapshot + // or change carries it. + files: Map<number, EnteFile>; } const asString = (v: unknown): string | undefined => @@ -101,18 +105,19 @@ const microsToMillis = (micros: number): number => Math.floor(micros / 1000); const byNewestPhoto = (a: PhotoRecord, b: PhotoRecord): number => b.takenAt - a.takenAt || b.fileID - a.fileID; -// Build one PhotoRecord from every membership of a file. The memberships share -// the same underlying file, so metadata is read from a single representative -// (the most recently synced, lowest collection id to break ties); `albumIDs` -// gathers them all. -const toPhotoRecord = ( - fileID: number, - memberships: EnteFile[], -): PhotoRecord => { - const albumIDs = memberships - .map((m) => m.collectionID) - .sort((a, b) => a - b); - const rep = memberships.reduce((best, m) => +// A photo's `takenAt` in milliseconds: `pubMagicMetadata.editedTime` when the +// user edited the date, else basic-metadata `creationTime`. +export const takenAtOf = (file: EnteFile): number => + microsToMillis( + asNumber(file.pubMagicMetadata?.editedTime) ?? + file.metadata.creationTime, + ); + +// The membership a file's record is read from: the most recently synced, lowest +// collection id to break ties. Whatever dates a file's save path takes this +// membership too, so the path always carries the record's `takenAt`. +export const representative = (memberships: EnteFile[]): EnteFile => + memberships.reduce((best, m) => m.updationTime > best.updationTime || (m.updationTime === best.updationTime && m.collectionID < best.collectionID) @@ -120,17 +125,28 @@ const toPhotoRecord = ( : best, ); +// Build one PhotoRecord from every membership of a file. The memberships share +// the same underlying file, so metadata is read from a single representative; +// `albumIDs` gathers them all. +const toPhotoRecord = ( + fileID: number, + memberships: EnteFile[], +): PhotoRecord => { + const albumIDs = memberships + .map((m) => m.collectionID) + .sort((a, b) => a - b); + const rep = representative(memberships); + const pub = rep.pubMagicMetadata ?? {}; const priv = rep.magicMetadata ?? {}; - const takenAtMicros = asNumber(pub.editedTime) ?? rep.metadata.creationTime; const visibility = asNumber(priv.visibility); const record: PhotoRecord = { fileID, albumIDs, title: asString(pub.editedName) ?? rep.metadata.title, - takenAt: microsToMillis(takenAtMicros), + takenAt: takenAtOf(rep), modifiedAt: microsToMillis(rep.metadata.modificationTime), fileType: rep.metadata.fileType, isArchived: visibility === VISIBILITY_ARCHIVED, @@ -198,6 +214,7 @@ export const deriveRecords = ( } const photos = new Map<number, PhotoRecord>(); + const photoFiles = new Map<number, EnteFile>(); const takenAtByFile = new Map<number, number>(); for (const [fileID, memberships] of byFileID) { const record = toPhotoRecord(fileID, memberships); @@ -209,6 +226,7 @@ export const deriveRecords = ( record.thumbnailPath = paths.thumbnailPath; } photos.set(fileID, record); + photoFiles.set(fileID, representative(memberships)); takenAtByFile.set(fileID, record.takenAt); } @@ -217,7 +235,7 @@ export const deriveRecords = ( albums.set(c.id, toAlbumRecord(c, files, takenAtByFile)); } - return { albums, photos }; + return { albums, photos, files: photoFiles }; }; // Sorted, GUI-ready arrays: albums newest updated first, photos newest first. diff --git a/src/library/store.ts b/src/library/store.ts index 56fe0dd..e74f8e1 100644 --- a/src/library/store.ts +++ b/src/library/store.ts @@ -16,6 +16,7 @@ import { dirname } from "node:path"; import { writeAtomic } from "../download/index.js"; import type { Collection, EnteFile, Microseconds } from "../model/types.js"; +import { representative } from "./records.js"; // Bumped only when the on-disk shape changes incompatibly. A file written // under a different version is discarded on load (see `load`): re-fetching @@ -179,14 +180,16 @@ export class MetadataStore { return this.files.get(fileKey(collectionID, fileID)); } - // Any membership of a file, or undefined. Every membership re-wraps the - // same underlying content key, so any one is enough to fetch the bytes; - // the content cache resolves a fileID to a file this way. + // The membership of a file its record is read from (`representative`), or + // undefined. Any membership could fetch the bytes, but the content cache + // resolves a fileID to a file this way so that it dates the save path + // from the same membership as `photo.savePath`. getFileByID(fileID: number): EnteFile | undefined { + const memberships: EnteFile[] = []; for (const file of this.files.values()) { - if (file.id === fileID) return file; + if (file.id === fileID) memberships.push(file); } - return undefined; + return memberships.length > 0 ? representative(memberships) : undefined; } listFiles(collectionID: number): EnteFile[] { diff --git a/test/cli/backup.test.ts b/test/cli/backup.test.ts index 1202ef3..57c18b9 100644 --- a/test/cli/backup.test.ts +++ b/test/cli/backup.test.ts @@ -1,16 +1,16 @@ /** * Tests for the `quak backup` logic, now built on the library API (issue #51). * - * `lib.backup({ downloadDirectory })` refreshes the library, fetches each - * pending file's original through the content cache/pools, and materialises the - * unchanged on-disk layout: + * `lib.backup({ downloadDirectory })` refreshes the library, puts each pending + * file's original at its save path through the content cache/pools, and + * materialises the on-disk layout: * * <downloadDirectory>/ - * originals/ - * <fileID>.<ext> the decrypted bytes ("present means complete") - * <fileID>.json per-file metadata sidecar (rebuilt each run) + * YYYY/YYYY-MM/YYYY-MM-DD/ + * YYYY-MM-DD.<fileID>.<ext> the decrypted bytes ("present means complete") + * YYYY-MM-DD.<fileID>.json per-file metadata sidecar (rebuilt each run) * collections/ - * <name>/<title> symlink into ../originals (rebuilt each run) + * <name>/<title> symlink to the original (rebuilt each run) * <name>.json per-collection metadata (rebuilt each run) * failures.json durable ledger of unresolved failures * @@ -94,6 +94,18 @@ const USER_ID = 42; // Decrypted-byte length each stub original writes, keyed by fileID. const SIZE_BY_ID: Record<number, number> = { 100: 3000, 101: 2000, 200: 1500 }; +// Every file is taken at noon local time on 2026-03-01, in microseconds as Ente +// stores times, so the machine's time zone cannot move it to another day. Its +// original is saved in the folder `DAY`, named `2026-03-01.<fileID>.<ext>`. +const TAKEN = new Date(2026, 2, 1, 12).getTime() * 1000; +const DAY = join("2026", "2026-03", "2026-03-01"); + +// Where the backup in `outDir` saves `name` (`<fileID>.<ext>`), and the target +// of an album folder's symlink to it. +const saved = (outDir: string, name: string): string => + join(outDir, DAY, `2026-03-01.${name}`); +const linkTo = (name: string): string => `../../${DAY}/2026-03-01.${name}`; + const collection = (id: number, name: string): Collection => ({ id, ownerID: USER_ID, @@ -112,7 +124,7 @@ const file = (id: number, collectionID: number, title: string): EnteFile => ({ metadata: { title, fileType: "image", - creationTime: 1, + creationTime: TAKEN, modificationTime: 1, }, file: { decryptionHeader: "aGVhZGVy" }, @@ -253,7 +265,11 @@ describe("lib.backup", () => { it("throws before any network when no downloadDirectory is given", async () => { const source = stubSource(); const lib = await openLibrary(source); - await expect(lib.backup()).rejects.toThrow(/downloadDirectory/i); + // The library always has a download directory, so only an empty one + // given to backup() reaches runBackup as none. + await expect(lib.backup({ downloadDirectory: "" })).rejects.toThrow( + /downloadDirectory/i, + ); expect(source.originalCalls).toBe(0); lib.close(); }); @@ -271,28 +287,22 @@ describe("lib.backup", () => { expect(result.failed).toBe(0); expect(result.errors).toEqual([]); - // Originals under originals/<fileID>.<ext>. - expect(readFileSync(join(outDir, "originals", "100.jpg")).length).toBe( - 3000, - ); - expect(readFileSync(join(outDir, "originals", "101.jpg")).length).toBe( - 2000, - ); - expect(readFileSync(join(outDir, "originals", "200.png")).length).toBe( - 1500, - ); + // Originals at YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>. + expect(readFileSync(saved(outDir, "100.jpg")).length).toBe(3000); + expect(readFileSync(saved(outDir, "101.jpg")).length).toBe(2000); + expect(readFileSync(saved(outDir, "200.png")).length).toBe(1500); // Per-file metadata sidecar. const sidecar = JSON.parse( - readFileSync(join(outDir, "originals", "100.json"), "utf-8"), + readFileSync(saved(outDir, "100.json"), "utf-8"), ); expect(sidecar.id).toBe(100); expect(sidecar.metadata.title).toBe("beach.jpg"); - // Collection dirs contain symlinks into ../originals. + // Collection dirs contain symlinks into the date folders. const beach = join(outDir, "collections", "Vacation", "beach.jpg"); expect(lstatSync(beach).isSymbolicLink()).toBe(true); - expect(readlinkSync(beach)).toContain("originals"); + expect(readlinkSync(beach)).toContain(DAY); expect(readFileSync(beach).length).toBe(3000); // Per-collection metadata JSON. @@ -316,7 +326,7 @@ describe("lib.backup", () => { expect(result.failed).toBe(0); // The title has no usable extension, so the original is `.bin`. - expect(existsSync(join(outDir, "originals", "300.bin"))).toBe(true); + expect(existsSync(saved(outDir, "300.bin"))).toBe(true); const link = join( outDir, "collections", @@ -366,9 +376,9 @@ describe("lib.backup", () => { expect(result.errors[0]!.title).toBe("sunset.jpg"); // The two good files are on disk; the failed one is not. - expect(existsSync(join(outDir, "originals", "100.jpg"))).toBe(true); - expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true); - expect(existsSync(join(outDir, "originals", "101.jpg"))).toBe(false); + expect(existsSync(saved(outDir, "100.jpg"))).toBe(true); + expect(existsSync(saved(outDir, "200.png"))).toBe(true); + expect(existsSync(saved(outDir, "101.jpg"))).toBe(false); expect( existsSync(join(outDir, "collections", "Vacation", "sunset.jpg")), ).toBe(false); @@ -403,7 +413,7 @@ describe("lib.backup", () => { const r3 = await lib.backup({ downloadDirectory: outDir }); expect(r3.failed).toBe(0); expect(r3.skipped).toBe(2); - expect(existsSync(join(outDir, "originals", "101.jpg"))).toBe(true); + expect(existsSync(saved(outDir, "101.jpg"))).toBe(true); // A ledger with no remaining failures is removed. expect(existsSync(join(outDir, "failures.json"))).toBe(false); lib.close(); @@ -423,8 +433,8 @@ describe("lib.backup", () => { const result = await lib.backup({ downloadDirectory: outDir }); // Every original still downloads despite the symlink failure. - expect(existsSync(join(outDir, "originals", "100.jpg"))).toBe(true); - expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true); + expect(existsSync(saved(outDir, "100.jpg"))).toBe(true); + expect(existsSync(saved(outDir, "200.png"))).toBe(true); // The other symlinks are still built. expect( lstatSync( @@ -454,7 +464,7 @@ describe("lib.backup", () => { await lib.backup({ downloadDirectory: outDir }); // Corrupt a sidecar and delete a symlink between runs. - writeFileSync(join(outDir, "originals", "100.json"), "not json"); + writeFileSync(saved(outDir, "100.json"), "not json"); rmSync(join(outDir, "collections", "Vacation", "beach.jpg")); const result = await lib.backup({ downloadDirectory: outDir }); @@ -462,7 +472,7 @@ describe("lib.backup", () => { // The derived views are repaired from the model. const sidecar = JSON.parse( - readFileSync(join(outDir, "originals", "100.json"), "utf-8"), + readFileSync(saved(outDir, "100.json"), "utf-8"), ); expect(sidecar.metadata.title).toBe("beach.jpg"); expect( @@ -485,8 +495,8 @@ describe("lib.backup", () => { expect(result.totalFiles).toBe(1); expect(result.downloaded).toBe(1); - expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true); - expect(existsSync(join(outDir, "originals", "100.jpg"))).toBe(false); + expect(existsSync(saved(outDir, "200.png"))).toBe(true); + expect(existsSync(saved(outDir, "100.jpg"))).toBe(false); expect(existsSync(join(outDir, "collections", "Work.json"))).toBe(true); expect(existsSync(join(outDir, "collections", "Vacation.json"))).toBe( false, @@ -530,7 +540,7 @@ describe("lib.backup", () => { expect(result.totalFiles).toBe(1); expect(result.failed).toBe(0); - expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true); + expect(existsSync(saved(outDir, "200.png"))).toBe(true); expect(existsSync(join(outDir, "failures.json"))).toBe(false); lib.close(); }); @@ -572,7 +582,7 @@ describe("lib.backup", () => { it("fetches each original once and writes it only into the backup", async () => { // A backup of a 500 GB account must write 500 GB, not a copy in the // cache as well: an original fetched for the backup goes straight - // into its originals/, and the cache records it there. + // to its save path, and the cache records it there. const source = stubSource(); const lib = await openLibrary(source); const outDir = join(root, "backup"); @@ -582,23 +592,78 @@ describe("lib.backup", () => { expect(result.downloaded).toBe(3); expect(source.originalCalls).toBe(3); expect(readdirSync(join(root, "cache", "originals"))).toEqual([]); - const stored = readdirSync(join(outDir, "originals")).filter( + const stored = readdirSync(join(outDir, DAY)).filter( (name) => !name.endsWith(".json"), ); - expect(stored.sort()).toEqual(["100.jpg", "101.jpg", "200.png"]); + expect(stored.sort()).toEqual([ + "2026-03-01.100.jpg", + "2026-03-01.101.jpg", + "2026-03-01.200.png", + ]); // The cache counts the backup's copy as present: reading the // original afterwards fetches nothing and answers with that copy. const read = await lib.photos.byID({ fileID: 100 })!.original(); - expect(read.path).toBe(join(outDir, "originals", "100.jpg")); + expect(read.path).toBe(saved(outDir, "100.jpg")); expect(source.originalCalls).toBe(3); await lib.close(); }); + it("saves a file in two albums at its photo's save path and links it from both", async () => { + // The date edited in Ente has reached Work's copy, synced later, but + // Vacation's copy still has an earlier edit. + const older = file(100, 1, "beach.jpg"); + older.pubMagicMetadata = { + editedTime: new Date(2026, 1, 1, 12).getTime() * 1000, + }; + const newer = file(100, 2, "beach.jpg"); + newer.updationTime = 2; + newer.pubMagicMetadata = { + editedTime: new Date(2026, 3, 15, 12).getTime() * 1000, + }; + class SharedFileClient extends MockClient { + override async filesSince(args: { + collectionID: number; + }): Promise<FilesPage> { + const files = [args.collectionID === 1 ? older : newer]; + return { files, deleted: [], cursor: 1 }; + } + } + const outDir = join(root, "backup"); + const lib = await Library.open({ + client: new SharedFileClient(), + cacheDirectory: join(root, "cache"), + downloadDirectory: outDir, + contentSource: stubSource(), + refreshIntervalSeconds: 3600, + precacheThumbnails: false, + precacheOriginals: false, + }); + const photo = lib.photos.byID({ fileID: 100 })!; + + const result = await lib.backup(); + + expect(result.totalFiles).toBe(1); + expect(result.downloaded).toBe(1); + expect(result.failed).toBe(0); + expect(photo.savePath).toBe( + join(outDir, "2026", "2026-04", "2026-04-15", "2026-04-15.100.jpg"), + ); + expect(photo.isLocal).toBe(true); + expect(existsSync(join(outDir, "2026", "2026-02"))).toBe(false); + const target = "../../2026/2026-04/2026-04-15/2026-04-15.100.jpg"; + for (const album of ["Vacation", "Work"]) { + expect( + readlinkSync(join(outDir, "collections", album, "beach.jpg")), + ).toBe(target); + } + await lib.close(); + }); + it("fsyncs an original copied from the cache before the rename and its directory after", async () => { const lib = await openLibrary(stubSource()); const outDir = join(root, "backup"); - const originals = join(outDir, "originals"); - const dest = join(originals, "100.jpg"); + const day = join(outDir, DAY); + const dest = saved(outDir, "100.jpg"); // Only an original already in the cache is copied into the backup; // one fetched for the backup is written there by the download writer. await lib.photos.byID({ fileID: 100 })!.original(); @@ -609,34 +674,50 @@ describe("lib.backup", () => { const at = fsEvents.indexOf(`rename:${dest}`); expect(at).toBeGreaterThan(0); expect(fsEvents[at - 1]).toMatch( - /^sync:.*\/\.quak-backup-100\.jpg-\d+-[0-9a-z]*\.tmp$/, + /^sync:.*\/\.quak-backup-2026-03-01\.100\.jpg-\d+-[0-9a-z]*\.tmp$/, ); - expect(fsEvents[at + 1]).toBe(`sync:${originals}`); + expect(fsEvents[at + 1]).toBe(`sync:${day}`); lib.close(); }); it("removes temp files left by a killed backup but not those of one still running", async () => { const outDir = join(root, "backup"); - const originals = join(outDir, "originals"); - mkdirSync(originals, { recursive: true }); + const day = join(outDir, DAY); + mkdirSync(day, { recursive: true }); // A child that has already exited: its process ID is not running. const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid; - const leftover = `.quak-backup-100.jpg-${exitedPID}-abc123.tmp`; + const leftover = `.quak-backup-2026-03-01.100.jpg-${exitedPID}-abc123.tmp`; // This test's own process stands in for a backup running at the same // time. - const inProgress = `.quak-backup-101.jpg-${process.pid}-def456.tmp`; - writeFileSync(join(originals, leftover), "partial"); - writeFileSync(join(originals, inProgress), "partial"); + const inProgress = `.quak-backup-2026-03-01.101.jpg-${process.pid}-def456.tmp`; + writeFileSync(join(day, leftover), "partial"); + writeFileSync(join(day, inProgress), "partial"); const lib = await openLibrary(stubSource()); await lib.backup({ downloadDirectory: outDir }); - const names = readdirSync(originals); + const names = readdirSync(day); expect(names).not.toContain(leftover); expect(names).toContain(inProgress); lib.close(); }); + it("removes temp files left in a date folder no file in the backup is saved in", async () => { + const outDir = join(root, "backup"); + // As for a file since deleted, or given another date, after a run was + // killed while writing it. + const otherDay = join(outDir, "2025", "2025-01", "2025-01-02"); + mkdirSync(otherDay, { recursive: true }); + const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid; + writeFileSync(join(otherDay, `.quak-${exitedPID}-abc123.tmp`), "x"); + const lib = await openLibrary(stubSource()); + + await lib.backup({ downloadDirectory: outDir }); + + expect(readdirSync(otherDay)).toEqual([]); + lib.close(); + }); + it("removes leftover temp files in thumbnails/ but not those of a backup still running", async () => { const outDir = join(root, "backup"); const thumbnails = join(outDir, "thumbnails"); @@ -709,7 +790,7 @@ describe("the refresh before a backup", () => { const result = await backup; expect(result.totalFiles).toBe(4); - expect(existsSync(join(outDir, "originals", "300.jpg"))).toBe(true); + expect(existsSync(saved(outDir, "300.jpg"))).toBe(true); await lib.close(); }); @@ -728,7 +809,7 @@ describe("the refresh before a backup", () => { expect(source.originalCalls).toBe(0); expect(readFileSync(ledgerPath, "utf-8")).toBe(ledgerBefore); - expect(existsSync(join(outDir, "originals"))).toBe(false); + expect(existsSync(join(outDir, DAY))).toBe(false); await lib.close(); }); @@ -819,13 +900,13 @@ describe("backup album folders", () => { expect(result.failed).toBe(0); expect(tree(outDir)).toEqual([ "Trip (10)/", - "Trip (10)/IMG_0001 (1).JPG -> ../../originals/1.JPG", - "Trip (10)/IMG_0001 (2).JPG -> ../../originals/2.JPG", - "Trip (10)/img_0001 (4).jpg -> ../../originals/4.jpg", - "Trip (10)/other.jpg -> ../../originals/3.jpg", + `Trip (10)/IMG_0001 (1).JPG -> ${linkTo("1.JPG")}`, + `Trip (10)/IMG_0001 (2).JPG -> ${linkTo("2.JPG")}`, + `Trip (10)/img_0001 (4).jpg -> ${linkTo("4.jpg")}`, + `Trip (10)/other.jpg -> ${linkTo("3.jpg")}`, "Trip (10).json", "Trip (11)/", - "Trip (11)/other.jpg -> ../../originals/3.jpg", + `Trip (11)/other.jpg -> ${linkTo("3.jpg")}`, "Trip (11).json", ]); expect(albumID(outDir, "Trip (10).json")).toBe(10); @@ -858,14 +939,14 @@ describe("backup album folders", () => { expect(result.failed).toBe(0); expect(tree(outDir)).toEqual([ "Trip (10)/", - "Trip (10)/IMG (6) (5).JPG -> ../../originals/5.JPG", - "Trip (10)/IMG (6).JPG -> ../../originals/6.JPG", - "Trip (10)/IMG (7).JPG -> ../../originals/7.JPG", + `Trip (10)/IMG (6) (5).JPG -> ${linkTo("5.JPG")}`, + `Trip (10)/IMG (6).JPG -> ${linkTo("6.JPG")}`, + `Trip (10)/IMG (7).JPG -> ${linkTo("7.JPG")}`, "Trip (10).json", "Trip (11)/", - "Trip (11)/a.jpg -> ../../originals/8.jpg", + `Trip (11)/a.jpg -> ${linkTo("8.jpg")}`, "Trip (11) (12)/", - "Trip (11) (12)/b.jpg -> ../../originals/9.jpg", + `Trip (11) (12)/b.jpg -> ${linkTo("9.jpg")}`, "Trip (11) (12).json", "Trip (11).json", ]); @@ -931,13 +1012,13 @@ describe("backup album folders", () => { expect(scoped.failed).toBe(0); expect(before).toEqual([ "Trip (10)/", - "Trip (10)/a.jpg -> ../../originals/1.jpg", + `Trip (10)/a.jpg -> ${linkTo("1.jpg")}`, "Trip (10).json", "Work/", - "Work/c.jpg -> ../../originals/3.jpg", + `Work/c.jpg -> ${linkTo("3.jpg")}`, "Work.json", "trip (11)/", - "trip (11)/b.jpg -> ../../originals/2.jpg", + `trip (11)/b.jpg -> ${linkTo("2.jpg")}`, "trip (11).json", ]); expect(tree(outDir)).toEqual(before); @@ -990,13 +1071,13 @@ describe("backup album folders", () => { "Mine/", "Mine/keep.txt", "Office/", - "Office/a.jpg -> ../../originals/5.jpg", + `Office/a.jpg -> ${linkTo("5.jpg")}`, "Office.json", "Trip/", - "Trip/IMG_0001.JPG -> ../../originals/1.JPG", + `Trip/IMG_0001.JPG -> ${linkTo("1.JPG")}`, "Trip/mine -> ../elsewhere", "Trip/notes.txt", - "Trip/other.jpg -> ../../originals/3.jpg", + `Trip/other.jpg -> ${linkTo("3.jpg")}`, "Trip.json", "Work/", "Work/keep.txt", @@ -1005,7 +1086,7 @@ describe("backup album folders", () => { }); // One album backed up, then a folder the user made beside it holding a - // symlink into originals/, with `json` (if given) as its sibling JSON. + // symlink to an original, with `json` (if given) as its sibling JSON. const backupWithUserFolder = async ( json: string | undefined, ): Promise<{ outDir: string; failed: number }> => { @@ -1019,10 +1100,7 @@ describe("backup album folders", () => { await runBackup(lib, { downloadDirectory: outDir }); const collectionsDir = join(outDir, "collections"); mkdirSync(join(collectionsDir, "Mine")); - symlinkSync( - "../../originals/1.jpg", - join(collectionsDir, "Mine", "a.jpg"), - ); + symlinkSync(linkTo("1.jpg"), join(collectionsDir, "Mine", "a.jpg")); if (json !== undefined) { writeFileSync(join(collectionsDir, "Mine.json"), json); } @@ -1036,9 +1114,9 @@ describe("backup album folders", () => { expect(failed).toBe(0); expect(tree(outDir)).toEqual([ "Mine/", - "Mine/a.jpg -> ../../originals/1.jpg", + `Mine/a.jpg -> ${linkTo("1.jpg")}`, "Trip/", - "Trip/a.jpg -> ../../originals/1.jpg", + `Trip/a.jpg -> ${linkTo("1.jpg")}`, "Trip.json", ]); }); @@ -1050,10 +1128,10 @@ describe("backup album folders", () => { expect(failed).toBe(0); expect(tree(outDir)).toEqual([ "Mine/", - "Mine/a.jpg -> ../../originals/1.jpg", + `Mine/a.jpg -> ${linkTo("1.jpg")}`, "Mine.json", "Trip/", - "Trip/a.jpg -> ../../originals/1.jpg", + `Trip/a.jpg -> ${linkTo("1.jpg")}`, "Trip.json", ]); expect( @@ -1087,23 +1165,16 @@ describe("backup of live photos", () => { const open = (files: EnteFile[], bodies: Map<number, Uint8Array>) => openLibrary(cdnSource(bodies), new TripClient(files)); - // What an earlier version stored for live photo 500: the ZIP under the - // image's name, and its link. - const earlierZIP = (outDir: string): void => { - mkdirSync(join(outDir, "originals"), { recursive: true }); - mkdirSync(join(outDir, "collections", "Trip"), { recursive: true }); - writeFileSync(join(outDir, "originals", "500.HEIC"), livePhotoZip()); - symlinkSync( - "../../originals/500.HEIC", - join(outDir, "collections", "Trip", "IMG_0500.HEIC"), - ); - }; - - const stored = ["500.heic", "500.json", "500.livephoto.json", "500.mov"]; + const stored = [ + "2026-03-01.500.heic", + "2026-03-01.500.json", + "2026-03-01.500.livephoto.json", + "2026-03-01.500.mov", + ]; const linked = [ "Trip/", - "Trip/IMG_0500.heic -> ../../originals/500.heic", - "Trip/IMG_0500.mov -> ../../originals/500.mov", + `Trip/IMG_0500.heic -> ${linkTo("500.heic")}`, + `Trip/IMG_0500.mov -> ${linkTo("500.mov")}`, "Trip.json", ]; @@ -1113,16 +1184,15 @@ describe("backup of live photos", () => { ); const lib = await open([live], new Map([[500, body]])); const outDir = join(root, "backup"); - const originals = join(outDir, "originals"); const result = await lib.backup({ downloadDirectory: outDir }); expect(result).toMatchObject({ downloaded: 1, failed: 0 }); - expect(readdirSync(originals).sort()).toEqual(stored); - expect(readFileSync(join(originals, "500.heic"))).toEqual( + expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored); + expect(readFileSync(saved(outDir, "500.heic"))).toEqual( Buffer.from(IMAGE), ); - expect(readFileSync(join(originals, "500.mov"))).toEqual( + expect(readFileSync(saved(outDir, "500.mov"))).toEqual( Buffer.from(VIDEO), ); expect(tree(outDir)).toEqual(linked); @@ -1149,39 +1219,22 @@ describe("backup of live photos", () => { expect(tree(outDir)).toEqual([ "Trip/", - "Trip/IMG_0001 (500).heic -> ../../originals/500.heic", - "Trip/IMG_0001 (500).mov -> ../../originals/500.mov", - "Trip/IMG_0001 (501).heic -> ../../originals/501.heic", - "Trip/IMG_0001 (501).mov -> ../../originals/501.mov", + `Trip/IMG_0001 (500).heic -> ${linkTo("500.heic")}`, + `Trip/IMG_0001 (500).mov -> ${linkTo("500.mov")}`, + `Trip/IMG_0001 (501).heic -> ${linkTo("501.heic")}`, + `Trip/IMG_0001 (501).mov -> ${linkTo("501.mov")}`, "Trip.json", ]); await lib.close(); }); - it("replaces the ZIP an earlier version stored, and its link", async () => { - const { file: live, body } = await asLivePhoto( - file(500, 10, "IMG_0500.HEIC"), - ); - const outDir = join(root, "backup"); - earlierZIP(outDir); - const lib = await open([live], new Map([[500, body]])); - - const result = await lib.backup({ downloadDirectory: outDir }); - - expect(result).toMatchObject({ downloaded: 1, failed: 0 }); - expect(readdirSync(join(outDir, "originals")).sort()).toEqual(stored); - expect(tree(outDir)).toEqual(linked); - await lib.close(); - }); - - it("stores nothing for a live photo that fails its hash, and keeps what was there", async () => { + it("stores nothing for a live photo that fails its hash", async () => { const { file: live, body } = await asLivePhoto( file(500, 10, "IMG_0500.HEIC"), livePhotoZip(), "not:the recorded hash", ); const outDir = join(root, "backup"); - earlierZIP(outDir); const lib = await open([live], new Map([[500, body]])); const result = await lib.backup({ downloadDirectory: outDir }); @@ -1189,12 +1242,8 @@ describe("backup of live photos", () => { expect(result).toMatchObject({ downloaded: 0, failed: 1 }); expect(result.errors.map((e) => e.fileID)).toEqual([500]); expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]); - expect(readdirSync(join(outDir, "originals"))).toEqual(["500.HEIC"]); - expect(tree(outDir)).toEqual([ - "Trip/", - "Trip/IMG_0500.HEIC -> ../../originals/500.HEIC", - "Trip.json", - ]); + expect(readdirSync(join(outDir, DAY))).toEqual([]); + expect(tree(outDir)).toEqual(["Trip/", "Trip.json"]); await lib.close(); }); @@ -1209,8 +1258,8 @@ describe("backup of live photos", () => { const result = await lib.backup({ downloadDirectory: outDir }); expect(result).toMatchObject({ downloaded: 1, failed: 0 }); - expect(readdirSync(join(outDir, "originals")).sort()).toEqual(stored); - expect(readFileSync(join(outDir, "originals", "500.mov"))).toEqual( + expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored); + expect(readFileSync(saved(outDir, "500.mov"))).toEqual( Buffer.from(VIDEO), ); expect(tree(outDir)).toEqual(linked); @@ -1219,23 +1268,6 @@ describe("backup of live photos", () => { await lib.close(); }); - it("replaces an earlier ZIP and its link with the image and video the cache holds", async () => { - const { file: live, body } = await asLivePhoto( - file(500, 10, "IMG_0500.HEIC"), - ); - const lib = await open([live], new Map([[500, body]])); - await lib.photos.byID({ fileID: 500 })!.original(); - const outDir = join(root, "backup"); - earlierZIP(outDir); - - const result = await lib.backup({ downloadDirectory: outDir }); - - expect(result).toMatchObject({ downloaded: 1, failed: 0 }); - expect(readdirSync(join(outDir, "originals")).sort()).toEqual(stored); - expect(tree(outDir)).toEqual(linked); - await lib.close(); - }); - it.each(["missing", "empty"])( "fetches a live photo again when the video its JSON file names is %s", async (state) => { @@ -1245,7 +1277,7 @@ describe("backup of live photos", () => { const lib = await open([live], new Map([[500, body]])); const outDir = join(root, "backup"); await lib.backup({ downloadDirectory: outDir }); - const video = join(outDir, "originals", "500.mov"); + const video = saved(outDir, "500.mov"); if (state === "missing") rmSync(video); else writeFileSync(video, ""); @@ -1271,7 +1303,7 @@ describe("backup of live photos", () => { const lib = await open([live], new Map([[500, body]])); const outDir = join(root, "backup"); await lib.backup({ downloadDirectory: outDir }); - const image = join(outDir, "originals", "500.heic"); + const image = saved(outDir, "500.heic"); if (state === "missing") rmSync(image); else writeFileSync(image, ""); @@ -1310,8 +1342,8 @@ describe("backup of live photos", () => { const read = await reader.photos.byID({ fileID: 500 })!.original(); expect(read).toEqual({ - path: join(outDir, "originals", "500.heic"), - videoPath: join(outDir, "originals", "500.mov"), + path: saved(outDir, "500.heic"), + videoPath: saved(outDir, "500.mov"), bytes: IMAGE.length, }); await reader.close(); diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index ff939ff..52d6069 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -757,7 +757,7 @@ describe("backup", () => { expect(code).toBe(1); expect(runText).toBe("quak: HTTP 401 from server\n"); expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n"); - expect(existsSync(join(dir, "originals"))).toBe(false); + expect(existsSync(dir)).toBe(false); }); }); diff --git a/test/library/content-eviction.test.ts b/test/library/content-eviction.test.ts index d0087b2..aa1a1ce 100644 --- a/test/library/content-eviction.test.ts +++ b/test/library/content-eviction.test.ts @@ -142,6 +142,7 @@ const buildCache = (args: { pools: new RequestPools(), source, cacheDirectory: cacheDir, + downloadDirectory: join(root, "photos"), getFile: (id) => byID.get(id), statfs: args.statfs, cacheOriginalsMaxBytes: args.cacheOriginalsMaxBytes, diff --git a/test/library/content-library.test.ts b/test/library/content-library.test.ts index 7e5ad27..198afd9 100644 --- a/test/library/content-library.test.ts +++ b/test/library/content-library.test.ts @@ -6,16 +6,18 @@ * `Photo` objects that fetch through it, `lib.thumbnails.ensure` drives it, and * a cached path shows up on the projected record. A library opened without a * content source leaves those methods throwing rather than silently doing - * nothing. It also covers a `Photo`'s `savePath`, `isLocal`, `content()` and - * `exif()`. + * nothing. It also covers a `Photo`'s `savePath`, `isLocal`, `download()`, + * `content()` and `exif()`. */ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdtempSync, readdirSync, + readFileSync, rmSync, existsSync, + statSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -38,6 +40,12 @@ import { const USER_ID = 7; +// Every file is taken at noon local time on 2026-03-01, in microseconds as Ente +// stores times, so the machine's time zone cannot move it to another day; it +// is saved in the folder `DAY`. +const TAKEN = new Date(2026, 2, 1, 12).getTime() * 1000; +const DAY = join("2026", "2026-03", "2026-03-01"); + const collection = (id: number): Collection => ({ id, ownerID: USER_ID, @@ -56,7 +64,7 @@ const file = (id: number, collectionID: number): EnteFile => ({ metadata: { title: `file-${id}.jpg`, fileType: "image", - creationTime: 1, + creationTime: TAKEN, modificationTime: 1, }, file: { decryptionHeader: "aGVhZGVy" }, @@ -224,6 +232,9 @@ describe("Library content wiring", () => { await expect( lib.photos.byID({ fileID: 1 })!.thumbnail(), ).rejects.toThrow(/content cache/i); + await expect( + lib.photos.byID({ fileID: 1 })!.download(), + ).rejects.toThrow(/content cache/i); await expect( lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }), ).rejects.toThrow(/content cache/i); @@ -341,7 +352,7 @@ describe("Photo save path, local copy, content and EXIF", () => { it("names where a backup writes the original, which is local once the backup has written it", async () => { const lib = await open(); const photo = lib.photos.byID({ fileID: 1 })!; - const savePath = join(root, "backup", "originals", "1.jpg"); + const savePath = join(root, "backup", DAY, "2026-03-01.1.jpg"); expect(photo.savePath).toBe(savePath); expect(photo.isLocal).toBe(false); @@ -359,23 +370,150 @@ describe("Photo save path, local copy, content and EXIF", () => { expect(existsSync(join(root, "cache", "originals", "1.jpg"))).toBe( true, ); - expect(existsSync(photo.savePath!)).toBe(false); + expect(existsSync(photo.savePath)).toBe(false); expect(photo.isLocal).toBe(false); await lib.close(); }); - it("has no save path and is not local without a download directory", async () => { + it("saves under photos/ in the working directory at open without a download directory", async () => { + const cwd = vi.spyOn(process, "cwd").mockReturnValue(root); const lib = await open({ downloadDirectory: undefined }); + cwd.mockRestore(); const photo = lib.photos.byID({ fileID: 1 })!; - expect(photo.savePath).toBeUndefined(); + expect(lib.downloadDirectory).toBe(join(root, "photos")); + expect(photo.savePath).toBe( + join(root, "photos", DAY, "2026-03-01.1.jpg"), + ); expect(photo.isLocal).toBe(false); await lib.close(); }); - it("has no save path and is not local without a content source", async () => { + it("refuses an empty download directory", async () => { + await expect(open({ downloadDirectory: "" })).rejects.toThrow( + /downloadDirectory is empty/, + ); + }); + + it("keeps a photo's save path after a refresh removes its file", async () => { + // The same account, whose album is deleted on the second refresh. + class AlbumDeletedClient extends MockClient { + override async collectionsSince(): Promise<CollectionsPage> { + if (!this.served) return super.collectionsSince(); + return { collections: [], deleted: [1], cursor: 2 }; + } + } + const lib = await open({ client: new AlbumDeletedClient() }); + const photo = lib.photos.byID({ fileID: 1 })!; + await photo.download(); + + await lib.fresh(); + + expect(lib.photos.byID({ fileID: 1 })).toBeUndefined(); + expect(photo.savePath).toBe( + join(root, "backup", DAY, "2026-03-01.1.jpg"), + ); + expect(photo.isLocal).toBe(true); + await lib.close(); + }); + + it("dates the save path and download() by the membership its takenAt comes from", async () => { + // One file in two albums. The date edited in Ente has reached album 2's + // copy, synced later, but album 1 still has an earlier edit. + const older = file(1, 1); + older.pubMagicMetadata = { + editedTime: new Date(2026, 1, 1, 12).getTime() * 1000, + }; + const newer = file(1, 2); + newer.updationTime = 2; + newer.pubMagicMetadata = { + editedTime: new Date(2026, 3, 15, 12).getTime() * 1000, + }; + const client = { + whoami: () => ({ email: "u@example.com", userID: USER_ID }), + collectionsSince: async (): Promise<CollectionsPage> => ({ + collections: [collection(1), collection(2)], + deleted: [], + cursor: 1, + }), + filesSince: async (args: { + collectionID: number; + }): Promise<FilesPage> => ({ + files: [args.collectionID === 1 ? older : newer], + deleted: [], + cursor: 1, + }), + }; + const lib = await open({ client }); + const photo = lib.photos.byID({ fileID: 1 })!; + + expect(photo.takenAt).toBe(new Date(2026, 3, 15, 12).getTime()); + expect(photo.savePath).toBe( + join( + root, + "backup", + "2026", + "2026-04", + "2026-04-15", + "2026-04-15.1.jpg", + ), + ); + + // download() writes to that same path, so the photo is then local. + const saved = await photo.download(); + expect(saved.path).toBe(photo.savePath); + expect(photo.isLocal).toBe(true); + expect(existsSync(join(root, "backup", "2026", "2026-02"))).toBe(false); + await lib.close(); + }); + + it("downloads a photo held across a refresh that edits its date to the save path it names", async () => { + // The same account, whose second refresh brings a date edited in Ente. + const edited = file(1, 1); + edited.updationTime = 2; + edited.pubMagicMetadata = { + editedTime: new Date(2026, 3, 15, 12).getTime() * 1000, + }; + class DateEditedClient extends MockClient { + refreshes = 0; + override async collectionsSince(): Promise<CollectionsPage> { + this.refreshes++; + return { + collections: [ + { ...collection(1), updationTime: this.refreshes }, + ], + deleted: [], + cursor: this.refreshes, + }; + } + override async filesSince(): Promise<FilesPage> { + return { + files: [this.refreshes === 1 ? file(1, 1) : edited], + deleted: [], + cursor: this.refreshes, + }; + } + } + const lib = await open({ client: new DateEditedClient() }); + const photo = lib.photos.byID({ fileID: 1 })!; + + await lib.fresh(); + expect(lib.photos.byID({ fileID: 1 })!.takenAt).toBe( + new Date(2026, 3, 15, 12).getTime(), + ); + + const saved = await photo.download(); + expect(saved.path).toBe(photo.savePath); + expect(saved.path).toBe(join(root, "backup", DAY, "2026-03-01.1.jpg")); + expect(photo.isLocal).toBe(true); + await lib.close(); + }); + + it("has a save path, and is not local, without a content source", async () => { const lib = await open({ contentSource: undefined }); const photo = lib.photos.byID({ fileID: 1 })!; - expect(photo.savePath).toBeUndefined(); + expect(photo.savePath).toBe( + join(root, "backup", DAY, "2026-03-01.1.jpg"), + ); expect(photo.isLocal).toBe(false); await lib.close(); }); @@ -387,18 +525,104 @@ describe("Photo save path, local copy, content and EXIF", () => { contentSource: cdnSource(new Map([[1, body]])), }); const photo = lib.photos.byID({ fileID: 1 })!; - const originals = join(root, "backup", "originals"); + const day = join(root, "backup", DAY); // Until then the name comes from the title, file-1.jpg; the backup // stores the image with the extension found inside the live photo. - expect(photo.savePath).toBe(join(originals, "1.jpg")); + expect(photo.savePath).toBe(join(day, "2026-03-01.1.jpg")); await lib.backup(); - expect(photo.savePath).toBe(join(originals, "1.heic")); + expect(photo.savePath).toBe(join(day, "2026-03-01.1.heic")); expect(photo.isLocal).toBe(true); expect(await photo.content()).toEqual(Buffer.from(IMAGE)); await lib.close(); }); + it("downloads an original the cache holds by copying it, without fetching it again", async () => { + const source = stubSource(); + const lib = await open({ contentSource: source }); + const photo = lib.photos.byID({ fileID: 1 })!; + await photo.original(); + expect(source.originalCalls()).toBe(1); + expect(photo.isLocal).toBe(false); + + const saved = await photo.download(); + + expect(source.originalCalls()).toBe(1); + expect(saved).toEqual({ + path: photo.savePath, + bytes: "orig-bytes".length, + }); + expect(readFileSync(photo.savePath, "utf-8")).toBe("orig-bytes"); + expect(photo.isLocal).toBe(true); + // The cache keeps its own copy. + expect(existsSync(join(root, "cache", "originals", "1.jpg"))).toBe( + true, + ); + await lib.close(); + }); + + it("downloads an original the cache does not hold straight to its save path", async () => { + const source = stubSource(); + const lib = await open({ contentSource: source }); + const photo = lib.photos.byID({ fileID: 1 })!; + + const saved = await photo.download(); + + expect(source.originalCalls()).toBe(1); + expect(saved.path).toBe(join(root, "backup", DAY, "2026-03-01.1.jpg")); + expect(readFileSync(saved.path, "utf-8")).toBe("orig-bytes"); + expect(photo.isLocal).toBe(true); + expect(readdirSync(join(root, "cache", "originals"))).toEqual([]); + await lib.close(); + }); + + it("does nothing when download() finds the original already at its save path", async () => { + const source = stubSource(); + const lib = await open({ contentSource: source }); + const photo = lib.photos.byID({ fileID: 1 })!; + const first = await photo.download(); + const written = statSync(first.path).ino; + + const second = await photo.download(); + + expect(second).toEqual(first); + expect(source.originalCalls()).toBe(1); + expect(statSync(second.path).ino).toBe(written); + await lib.close(); + }); + + it("downloads a live photo the cache holds as its image, its video and the JSON file naming them", async () => { + const { file: live, body } = await asLivePhoto(file(1, 1)); + const lib = await open({ + client: new FilesClient([live]), + contentSource: cdnSource(new Map([[1, body]])), + }); + const photo = lib.photos.byID({ fileID: 1 })!; + await photo.original(); + const day = join(root, "backup", DAY); + + const saved = await photo.download(); + + expect(saved).toEqual({ + path: join(day, "2026-03-01.1.heic"), + videoPath: join(day, "2026-03-01.1.mov"), + bytes: IMAGE.length, + }); + expect(readdirSync(day).sort()).toEqual([ + "2026-03-01.1.heic", + "2026-03-01.1.livephoto.json", + "2026-03-01.1.mov", + ]); + expect( + JSON.parse( + readFileSync(join(day, "2026-03-01.1.livephoto.json"), "utf-8"), + ), + ).toEqual({ image: "2026-03-01.1.heic", video: "2026-03-01.1.mov" }); + expect(photo.savePath).toBe(saved.path); + expect(photo.isLocal).toBe(true); + await lib.close(); + }); + it("reads the common EXIF fields of a JPEG original", async () => { const lib = await open({ contentSource: stubSource(JPEG_WITH_EXIF) }); expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({ diff --git a/test/library/content.test.ts b/test/library/content.test.ts index e0f0e28..f209359 100644 --- a/test/library/content.test.ts +++ b/test/library/content.test.ts @@ -7,8 +7,8 @@ * 1. **Fetch once, then serve from disk.** The first `original`/`thumbnail` * fetches through the request pool and stores the bytes; the next finds the * file present and returns its path with a single `skipped` event and no - * network. A file already sitting in the backup `downloadDirectory` counts - * as present too. + * network. A file already stored at its save path under the + * `downloadDirectory` counts as present too. * 2. **Present-means-complete.** Content appears only by the streaming atomic * writer's rename, so a file that exists is whole. The directory listing * taken at `open()` is the record of what is cached, and the orphan temp @@ -41,6 +41,7 @@ import { join } from "node:path"; import { ContentCache, + savePath, type ContentSource, type EnsureEvent, } from "../../src/library/content.js"; @@ -152,12 +153,48 @@ const buildCache = ( pools: args.pools ?? new RequestPools(), source, cacheDirectory: cacheDir, - downloadDirectory: args.downloadDirectory, + downloadDirectory: args.downloadDirectory ?? join(root, "photos"), getFile: (id) => byID.get(id), }); return { cache, source }; }; +// Microseconds, as Ente stores times, for noon local time on a day, so the +// machine's time zone cannot move the photo to another day. +const noon = (year: number, month: number, day: number): number => + new Date(year, month - 1, day, 12).getTime() * 1000; + +describe("savePath", () => { + it("files an original by year, month and day under the root", () => { + const f = file(12345, "IMG_0001.HEIC"); + f.metadata.creationTime = noon(2026, 3, 1); + + expect(savePath("/photos", f)).toBe( + "/photos/2026/2026-03/2026-03-01/2026-03-01.12345.HEIC", + ); + }); + + it("dates an original by the date the user set, when there is one", () => { + const f = file(7, "a.jpg"); + f.metadata.creationTime = noon(2026, 3, 1); + f.pubMagicMetadata = { editedTime: noon(1999, 12, 31) }; + + expect(savePath("/photos", f)).toBe( + "/photos/1999/1999-12/1999-12-31/1999-12-31.7.jpg", + ); + }); + + it("takes the extension from the title it was uploaded with, not a new name", () => { + const f = file(8, "upload"); + f.metadata.creationTime = noon(2026, 3, 1); + f.pubMagicMetadata = { editedName: "renamed.png" }; + + expect(savePath("/photos", f)).toBe( + "/photos/2026/2026-03/2026-03-01/2026-03-01.8.bin", + ); + }); +}); + describe("ContentCache.open", () => { it("creates the cache directories with 0700 permissions", async () => { const { cache } = buildCache(); @@ -274,11 +311,17 @@ describe("ContentCache.original / thumbnail", () => { it("serves a file already present in the download directory without fetching", async () => { const downloadDirectory = join(root, "backup"); - mkdirSync(join(downloadDirectory, "originals"), { recursive: true }); - const backupPath = join(downloadDirectory, "originals", "1.jpg"); + const day = join(downloadDirectory, "2026", "2026-03", "2026-03-01"); + mkdirSync(day, { recursive: true }); + const backupPath = join(day, "2026-03-01.1.jpg"); writeFileSync(backupPath, "from-backup"); + const f = file(1); + f.metadata.creationTime = noon(2026, 3, 1); - const { cache, source } = buildCache({ downloadDirectory }); + const { cache, source } = buildCache({ + downloadDirectory, + files: [f], + }); await cache.open(); const events: EnsureEvent["status"][] = []; @@ -649,6 +692,7 @@ describe("ContentCache live photos", () => { ]), ), cacheDirectory: cacheDir, + downloadDirectory: join(root, "photos"), getFile: (id) => [a.file, b.file].find((f) => f.id === id), // Room for one live photo, on a disk with plenty free. cacheOriginalsMaxBytes: size, diff --git a/test/library/precache.test.ts b/test/library/precache.test.ts index 977867f..021dfbe 100644 --- a/test/library/precache.test.ts +++ b/test/library/precache.test.ts @@ -280,6 +280,7 @@ describe("Precache eviction integration", () => { pools: new RequestPools(), source, cacheDirectory: cacheDir, + downloadDirectory: join(root, "photos"), getFile: (id) => byID.get(id), statfs, cacheOriginalsMaxBytes: 25, // holds two 10-byte originals @@ -348,6 +349,7 @@ describe("Precache preemption", () => { pools: new RequestPools({ contentConcurrency: 1 }), source, cacheDirectory: join(root, "cache"), + downloadDirectory: join(root, "photos"), getFile: (id) => byID.get(id), statfs: async () => ({ bsize: 1, bavail: 1_000_000_000 }), freeBelowBytes: 0, diff --git a/test/library/read.test.ts b/test/library/read.test.ts index 4bb7d45..ed90287 100644 --- a/test/library/read.test.ts +++ b/test/library/read.test.ts @@ -36,6 +36,7 @@ import { makeAlbumsAPI, makePhotosAPI, makeTimelineAPI, + type SavePathLookup, type TimelineGroup, } from "../../src/library/read.js"; import { Library } from "../../src/library/index.js"; @@ -101,12 +102,19 @@ const file = ( }; // Build the three API objects over one fixed projection, the way `Library` -// wires them over its live store. +// wires them over its live store. Save paths are covered in +// content-library.test.ts; these tests never ask for one. const apis = (records: DerivedRecords) => { const derive = () => records; + const saves: SavePathLookup = { + savePath: () => { + throw new Error("no save paths in these tests"); + }, + isLocal: () => false, + }; return { - albums: makeAlbumsAPI(derive), - photos: makePhotosAPI(derive), + albums: makeAlbumsAPI(derive, saves), + photos: makePhotosAPI(derive, saves), timeline: makeTimelineAPI(derive), }; }; From d40f339c0b2befe349f288ef3827ba1f2d1f2dbc Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 00:09:24 +0200 Subject: [PATCH 07/39] Photo: one async method per EXIF field (closes #148) `Photo` gains thirteen async methods, one per `PhotoExif` field and named after it: `make()`, `model()`, `lensModel()`, `dateTimeOriginal()`, `offsetTimeOriginal()`, `exposureTime()`, `fNumber()`, `iso()`, `focalLength()`, `orientation()`, `gpsLatitude()`, `gpsLongitude()` and `gpsAltitude()`. Each calls `exif()` and returns its one field, or `undefined` when the file lacks it. `exif()` is unchanged. `Photo` implements a type built from `PhotoExif`'s keys, so the type check fails when a field has no method. Each call reads the original again; a caller that wants several fields calls `exif()` once. Model: opus-5-5 --- README.md | 8 ++- TODO.md | 10 ++++ src/library/read.ts | 78 +++++++++++++++++++++++++--- test/library/content-library.test.ts | 34 ++++++++++-- 4 files changed, 119 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index bb365bc..1515a67 100644 --- a/README.md +++ b/README.md @@ -726,7 +726,7 @@ synchronous getters look at the disk and never touch the network: - `photo.isLocal` → `boolean` — whether the whole original is at `savePath`. A copy only in the cache does not count. -Five async methods may download: +These async methods may download: - `await photo.original(opts?)` → `{ path, bytes, videoPath? }` — the full-resolution file. For a live photo, `path` and `bytes` are its image's and @@ -748,6 +748,12 @@ Five async methods may download: UTC. EXIF is read from any image format exifreader reads (such as JPEG, HEIC/HEIF, AVIF, PNG, WebP and TIFF), a live photo's image included. Any other original gives `{}`, and a video gives `{}` without being downloaded. +- `await photo.make(opts?)`, and likewise `model()`, `lensModel()`, + `dateTimeOriginal()`, `offsetTimeOriginal()`, `exposureTime()`, `fNumber()`, + `iso()`, `focalLength()`, `orientation()`, `gpsLatitude()`, `gpsLongitude()` + and `gpsAltitude()` → one field of `exif()` each, typed as in `PhotoExif`, or + `undefined` when the file lacks it. Each calls `exif()` with its `opts`, so + each call reads the original again. They serve from the on-disk content cache when the bytes are present and otherwise fetch through the pools; `original()`, `content()` and `exif()` also diff --git a/TODO.md b/TODO.md index acc99ef..3d1af8a 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,16 @@ declares one. # Completed Steps +- 2026-10-01: A `Photo` has one async method for each field of `exif()`, named + and typed as in `PhotoExif`: `make()`, `model()`, `lensModel()`, + `dateTimeOriginal()`, `offsetTimeOriginal()`, `exposureTime()`, `fNumber()`, + `iso()`, `focalLength()`, `orientation()`, `gpsLatitude()`, `gpsLongitude()` + and `gpsAltitude()` (issue 148). Each calls `exif()` and returns its one + field, or undefined when the file lacks it. `Photo` implements a type with one + method per `PhotoExif` field, so the build's type check fails when a field has + no method. A test checks, on the JPEG and the HEIC, that each method gives the + same value as `exif()`. + - 2026-10-01: Each original's save path is `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>` under the library's download directory, which defaults to `photos` in the working directory (issue diff --git a/src/library/read.ts b/src/library/read.ts index ae040cb..3aac9ce 100644 --- a/src/library/read.ts +++ b/src/library/read.ts @@ -12,11 +12,11 @@ // plain records are the serializable surface, and `record()` returns one. // // A `Photo` also fetches its own bytes: `original()`, `thumbnail()`, -// `download()`, `content()` and `exif()` go through the on-disk content cache -// (issue #46), and are the one place in this module that may touch the -// network. A library opened without a content source leaves that cache absent, -// and those methods then throw. `savePath` and `isLocal` look only at the disk -// and need no cache. +// `download()`, `content()`, `exif()` and the methods that each return one +// field of `exif()` go through the on-disk content cache (issue #46), and are +// the one place in this module that may touch the network. A library opened +// without a content source leaves that cache absent, and those methods then +// throw. `savePath` and `isLocal` look only at the disk and need no cache. import { readFile } from "node:fs/promises"; @@ -42,11 +42,18 @@ const byNewest = (a: PhotoRecord, b: PhotoRecord): number => const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number => b.updationTime - a.updationTime || b.collectionID - a.collectionID; +// A method for each `PhotoExif` field, named after it, taking the options +// `exif()` takes and giving that field. `Photo` implements it, so the build's +// type check fails when `PhotoExif` has a field `Photo` has no method for. +type PhotoExifMethods = { + [K in keyof PhotoExif]-?: (opts?: ContentOptions) => Promise<PhotoExif[K]>; +}; + // A single photo. Field access mirrors `PhotoRecord`; `record()` returns the // underlying plain record for callers that need the IPC-safe value. `file` is // the membership the record is read from, so the save path carries the date of // `takenAt` and stays known after a refresh removes the file from the library. -export class Photo { +export class Photo implements PhotoExifMethods { constructor( private readonly rec: PhotoRecord, private readonly file: EnteFile, @@ -160,6 +167,65 @@ export class Photo { return readPhotoExif(await this.content(opts)); } + // One field of `exif()` each, named and typed as in `PhotoExif`, and + // undefined when the file lacks it. Each call runs `exif()`, which reads + // the original again. + async make(opts?: ContentOptions): Promise<PhotoExif["make"]> { + return (await this.exif(opts)).make; + } + async model(opts?: ContentOptions): Promise<PhotoExif["model"]> { + return (await this.exif(opts)).model; + } + async lensModel(opts?: ContentOptions): Promise<PhotoExif["lensModel"]> { + return (await this.exif(opts)).lensModel; + } + async dateTimeOriginal( + opts?: ContentOptions, + ): Promise<PhotoExif["dateTimeOriginal"]> { + return (await this.exif(opts)).dateTimeOriginal; + } + async offsetTimeOriginal( + opts?: ContentOptions, + ): Promise<PhotoExif["offsetTimeOriginal"]> { + return (await this.exif(opts)).offsetTimeOriginal; + } + async exposureTime( + opts?: ContentOptions, + ): Promise<PhotoExif["exposureTime"]> { + return (await this.exif(opts)).exposureTime; + } + async fNumber(opts?: ContentOptions): Promise<PhotoExif["fNumber"]> { + return (await this.exif(opts)).fNumber; + } + async iso(opts?: ContentOptions): Promise<PhotoExif["iso"]> { + return (await this.exif(opts)).iso; + } + async focalLength( + opts?: ContentOptions, + ): Promise<PhotoExif["focalLength"]> { + return (await this.exif(opts)).focalLength; + } + async orientation( + opts?: ContentOptions, + ): Promise<PhotoExif["orientation"]> { + return (await this.exif(opts)).orientation; + } + async gpsLatitude( + opts?: ContentOptions, + ): Promise<PhotoExif["gpsLatitude"]> { + return (await this.exif(opts)).gpsLatitude; + } + async gpsLongitude( + opts?: ContentOptions, + ): Promise<PhotoExif["gpsLongitude"]> { + return (await this.exif(opts)).gpsLongitude; + } + async gpsAltitude( + opts?: ContentOptions, + ): Promise<PhotoExif["gpsAltitude"]> { + return (await this.exif(opts)).gpsAltitude; + } + private cacheOrThrow(): PhotoContent { if (!this.cache) { throw new Error( diff --git a/test/library/content-library.test.ts b/test/library/content-library.test.ts index 198afd9..ffa0cc7 100644 --- a/test/library/content-library.test.ts +++ b/test/library/content-library.test.ts @@ -7,7 +7,7 @@ * a cached path shows up on the projected record. A library opened without a * content source leaves those methods throwing rather than silently doing * nothing. It also covers a `Photo`'s `savePath`, `isLocal`, `download()`, - * `content()` and `exif()`. + * `content()`, `exif()` and the methods that each return one field of `exif()`. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; @@ -644,8 +644,8 @@ describe("Photo save path, local copy, content and EXIF", () => { await lib.close(); }); - // What exif() returns for HEIC_WITH_EXIF, which holds the same values as - // JPEG_WITH_EXIF. + // What exif() returns for HEIC_WITH_EXIF, and for JPEG_WITH_EXIF, which + // holds the same values. const heicFields: PhotoExif = { make: "Canon", model: "EOS R5", @@ -686,9 +686,35 @@ describe("Photo save path, local copy, content and EXIF", () => { await lib.close(); }); + // The build's type check, not this test, makes sure `Photo` has a method + // for every `PhotoExif` field, whatever the fixtures hold: `Photo` + // implements a type with one method per field. This test checks that each + // method gives the same value as exif(). + it.each([ + ["JPEG", JPEG_WITH_EXIF], + ["HEIC", HEIC_WITH_EXIF], + ])( + "has a method for each field exif() returns, giving the same value, for a %s", + async (_, bytes) => { + const lib = await open({ contentSource: stubSource(bytes) }); + const photo = lib.photos.byID({ fileID: 1 })!; + const exif = await photo.exif(); + // The file holds every field, so every method is checked. + expect(exif).toStrictEqual(heicFields); + for (const [field, value] of Object.entries(exif)) { + expect(await photo[field as keyof PhotoExif]()).toStrictEqual( + value, + ); + } + await lib.close(); + }, + ); + it("returns no EXIF fields for an original that is not an image", async () => { const lib = await open(); - expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({}); + const photo = lib.photos.byID({ fileID: 1 })!; + expect(await photo.exif()).toStrictEqual({}); + expect(await photo.dateTimeOriginal()).toBeUndefined(); await lib.close(); }); From 10afa7a7f48f664940ea5455b0b64ff25844ec54 Mon Sep 17 00:00:00 2001 From: clawbot <sneak+clawbot@sneak.cloud> Date: Fri, 2 Oct 2026 00:33:00 +0200 Subject: [PATCH 08/39] Example script: download every album's photos and metadata (closes #144) `examples/download-albums.ts` logs in from `QUAK_EMAIL` and `QUAK_PASSWORD`, then walks every album from `await lib.fresh()`. For each photo it runs `photo.download()` to the photo's save path and writes `{savePath}.json`, which holds the photo's record and EXIF. For each album it writes `{dir}/albums/{collectionID}.json` with the album's name and its photos' save paths. A file is written only when its content changes, so a second run downloads and rewrites nothing. The script opens the library with prefetching off, as `quak backup` does. The build type-checks `examples/`. The README says how to run it. Model: opus-5-5 Co-authored-by: clawbot <sneak+clawbot@sneak.cloud> --- README.md | 42 +++- TODO.md | 6 + examples/download-albums.ts | 129 +++++++++++ test/examples/download-albums.test.ts | 317 ++++++++++++++++++++++++++ tsconfig.json | 2 +- 5 files changed, 491 insertions(+), 5 deletions(-) create mode 100644 examples/download-albums.ts create mode 100644 test/examples/download-albums.test.ts diff --git a/README.md b/README.md index 1515a67..12c99e2 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,36 @@ await lib.close(); The lower-level `Client` (login, session serialization, and the raw enumeration/download calls) is exported too and documented under Design below. +## Examples + +`examples/download-albums.ts` downloads every album's photos and their metadata +into a directory, `photos` in the working directory unless you name another. The +build compiles it; run it after `yarn install`: + +```bash +yarn build +QUAK_EMAIL=… QUAK_PASSWORD=… node dist/examples/download-albums.js [dir] +``` + +It opens the library with `precacheThumbnails` and `precacheOriginals` off, as +`quak backup` does, so the only file content it fetches is the originals it +saves. It asks on the terminal for a two-factor or email code when the account +requires one, and writes: + +- each photo's original at its save path under `dir`, as `photo.download()` + writes it: `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>`, and for a live + photo its image, its video and the `.livephoto.json` file naming them +- beside each original, a JSON file named after it with `.json` added, for + example `2026-03-01.12345.jpg.json`: the photo's record (`photo.record()`) + without its cache paths, and its EXIF fields (`photo.exif()`) under `exif` +- `albums/<collectionID>.json` for each album: its `collectionID`, its `name`, + and under `savePaths` the save paths of its photos relative to `dir`, newest + first + +A photo in several albums is downloaded once. A second run downloads nothing and +rewrites only the JSON files whose content changed. A failed download stops the +run; running it again carries on, since every photo already saved is skipped. + ## Entrypoints This repository adheres to the @@ -239,6 +269,9 @@ quak/ index.ts public library exports bin/ quak.ts CLI entrypoint (commander.js) + examples/ + download-albums.ts + download every album's photos and metadata test/ unit + integration tests (vitest) Makefile Dockerfile lint phase, test phase, compile @@ -247,10 +280,11 @@ quak/ ``` `make build` compiles that tree into `dist/`, preserving its shape: the library -lands in `dist/src/` and the CLI in `dist/bin/quak.js`, which is what -`package.json` points `main`, `types` and `bin` at. The compiler's `rootDir` is -the repository root rather than `src/`, because `bin/` is compiled too and -`rootDir` has to contain everything that is compiled. +lands in `dist/src/`, the examples in `dist/examples/`, and the CLI in +`dist/bin/quak.js`, which is what `package.json` points `main`, `types` and +`bin` at. The compiler's `rootDir` is the repository root rather than `src/`, +because `bin/` is compiled too and `rootDir` has to contain everything that is +compiled. ### Cryptography diff --git a/TODO.md b/TODO.md index 3d1af8a..29cfd56 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,12 @@ declares one. # Completed Steps +- 2026-10-01: `examples/download-albums.ts` logs in, opens the library, and for + every album downloads each photo to its save path, writes the photo's record + and EXIF fields to a JSON file beside it, and writes the album's photos to + `albums/<collectionID>.json` (issue 144). The build compiles it to + `dist/examples/`; the README's "Examples" section says how to run it. + - 2026-10-01: A `Photo` has one async method for each field of `exif()`, named and typed as in `PhotoExif`: `make()`, `model()`, `lensModel()`, `dateTimeOriginal()`, `offsetTimeOriginal()`, `exposureTime()`, `fNumber()`, diff --git a/examples/download-albums.ts b/examples/download-albums.ts new file mode 100644 index 0000000..7175f4f --- /dev/null +++ b/examples/download-albums.ts @@ -0,0 +1,129 @@ +// Download every album's photos to a directory, with each photo's metadata +// beside it, using only quak's public API. The README's "Examples" section +// describes the files it writes. +// +// yarn build +// QUAK_EMAIL=… QUAK_PASSWORD=… node dist/examples/download-albums.js [dir] + +import { realpathSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, relative } from "node:path"; +import { stdin, stdout } from "node:process"; +import { createInterface } from "node:readline/promises"; +import { pathToFileURL } from "node:url"; + +import { Client, Library } from "../src/index.js"; + +// Write `text` to `path` unless the file already holds exactly that, so a +// second run rewrites nothing. +async function writeIfChanged(path: string, text: string): Promise<void> { + const current = await readFile(path, "utf-8").catch(() => undefined); + if (current !== text) await writeFile(path, text); +} + +const pretty = (value: unknown): string => + JSON.stringify(value, null, 2) + "\n"; + +// For every album in `lib`, download each photo to its save path, write the +// photo's metadata to `{savePath}.json`, and write the album's photos to +// `{dir}/albums/{collectionID}.json`. Returns how many photos it downloaded +// and how many were already at their save paths. +export async function downloadAlbums( + lib: Library, + dir: string, +): Promise<{ downloaded: number; alreadyLocal: number }> { + let downloaded = 0; + let alreadyLocal = 0; + // A photo in several albums is handled once. + const done = new Set<number>(); + // fresh() waits for a refresh from the server and throws if it fails, so + // albums and photos added since the cache was last written are included. + const { albums } = await lib.fresh(); + for (const album of albums.list()) { + const savePaths: string[] = []; + for (const photo of album.photos.list()) { + if (!done.has(photo.fileID)) { + done.add(photo.fileID); + if (photo.isLocal) alreadyLocal++; + else downloaded++; + await photo.download(); + // The cache paths say where quak's cache keeps copies, not + // anything about the photo. + const record = { ...photo.record() }; + delete record.thumbnailPath; + delete record.originalPath; + const exif = await photo.exif(); + // savePath is read after download(): a live photo's names its + // image only once the image is stored. + await writeIfChanged( + `${photo.savePath}.json`, + pretty({ ...record, exif }), + ); + } + savePaths.push(relative(dir, photo.savePath)); + } + await mkdir(join(dir, "albums"), { recursive: true }); + await writeIfChanged( + join(dir, "albums", `${album.collectionID}.json`), + pretty({ + collectionID: album.collectionID, + name: album.name, + savePaths, + }), + ); + } + return { downloaded, alreadyLocal }; +} + +// Ask for a login code on the terminal. Client.login calls this only when the +// account requires a code. +async function ask(question: string): Promise<string> { + const terminal = createInterface({ input: stdin, output: stdout }); + try { + return await terminal.question(question); + } finally { + terminal.close(); + } +} + +async function main(): Promise<void> { + const email = process.env.QUAK_EMAIL; + const password = process.env.QUAK_PASSWORD; + if (!email || !password) { + console.error( + "Set QUAK_EMAIL and QUAK_PASSWORD to the account's email and password.", + ); + process.exit(1); + } + const dir = process.argv[2] ?? "photos"; + + const client = await Client.login({ + email, + password, + totp: () => ask("Two-factor code: "), + emailOTP: () => ask("Code sent to your email: "), + }); + const lib = await Library.open({ + client, + downloadDirectory: dir, + // As in `quak backup`: fetch only the originals this script saves, + // not every thumbnail and the recent originals into the cache too. + precacheThumbnails: false, + precacheOriginals: false, + }); + try { + const { downloaded, alreadyLocal } = await downloadAlbums(lib, dir); + console.log( + `${downloaded} photos downloaded, ${alreadyLocal} already local, in ${dir}`, + ); + } finally { + await lib.close(); + } +} + +// Run main() when node runs this file, not when a test imports it. argv[1] is +// the path as given, and import.meta.url has symlinks resolved. +const script = process.argv[1]; +if (script && pathToFileURL(realpathSync(script)).href === import.meta.url) { + await main(); +} diff --git a/test/examples/download-albums.test.ts b/test/examples/download-albums.test.ts new file mode 100644 index 0000000..8607b03 --- /dev/null +++ b/test/examples/download-albums.test.ts @@ -0,0 +1,317 @@ +/** + * The example script `examples/download-albums.ts` (issue #144), run twice + * against a stand-in account, as a user would run it twice. + * + * The account has two albums sharing one photo, and one of its photos is a + * live photo whose image is a HEIC with EXIF. The first run puts every + * original at its save path, writes each photo's metadata beside it and each + * album's photos under `albums/`. Before the second run the account gains an + * album holding a new photo. The second run downloads that photo and writes + * its files and the new album's, and fetches nothing else and changes no + * other file. + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { downloadAlbums } from "../../examples/download-albums.js"; +import { Library, type ContentSource } from "../../src/index.js"; +import type { CollectionsPage, FilesPage } from "../../src/client.js"; +import type { Collection, EnteFile } from "../../src/model/types.js"; +import { HEIC_WITH_EXIF } from "../exif-heic.js"; +import { + asLivePhoto, + cdnSource, + livePhotoHash, + livePhotoZip, + VIDEO, +} from "../live-photo.js"; + +const USER_ID = 7; + +// Every photo is taken at noon local time on 2026-03-01, so the machine's time +// zone cannot move it to another day; it is saved in the folder `DAY`. Ente +// stores times in microseconds. +const TAKEN_MS = new Date(2026, 2, 1, 12).getTime(); +const DAY = join("2026", "2026-03", "2026-03-01"); + +const collection = (id: number, name: string): Collection => ({ + id, + ownerID: USER_ID, + key: new Uint8Array([id]), + name, + type: "album", + updationTime: 1, + isShared: false, +}); + +const file = (id: number, collectionID: number): EnteFile => ({ + id, + collectionID, + ownerID: USER_ID, + key: new Uint8Array([id]), + metadata: { + title: `file-${id}.jpg`, + fileType: "image", + creationTime: TAKEN_MS * 1000, + modificationTime: TAKEN_MS * 1000, + }, + file: { decryptionHeader: "aGVhZGVy" }, + thumbnail: { decryptionHeader: "dGh1bWI=" }, + updationTime: 1, +}); + +let root: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "quak-download-albums-")); +}); + +afterEach(() => { + if (root && existsSync(root)) + rmSync(root, { recursive: true, force: true }); +}); + +// Every file and directory under `dir`, by path. +const entries = (dir: string): string[] => + readdirSync(dir, { recursive: true, encoding: "utf-8" }); + +// Set the modification time of everything under `dir` to the epoch, so that +// anything written there afterwards has a later one, however soon it comes. +const backdate = (dir: string): void => { + for (const name of entries(dir)) utimesSync(join(dir, name), 0, 0); +}; + +// The modification time of everything under `dir`, by path. +const mtimes = (dir: string): Map<string, number> => + new Map( + entries(dir).map((name) => [name, statSync(join(dir, name)).mtimeMs]), + ); + +const readJSON = (path: string): unknown => + JSON.parse(readFileSync(path, "utf-8")); + +describe("examples/download-albums.ts", () => { + it("downloads every album's photos with their metadata, and on a second run only what the account gained", async () => { + // Album 1, "Trip", holds photos 1 and 2. Album 2, "Family", holds + // photo 2 and photo 3, a live photo. + const live = await asLivePhoto( + file(3, 2), + livePhotoZip({ "image.heic": HEIC_WITH_EXIF, "video.mov": VIDEO }), + livePhotoHash(HEIC_WITH_EXIF, VIDEO), + ); + const collections = [collection(1, "Trip"), collection(2, "Family")]; + const filesByAlbum = new Map<number, EnteFile[]>([ + [1, [file(1, 1), file(2, 1)]], + [2, [file(2, 2), live.file]], + ]); + const client = { + whoami: () => ({ email: "u@example.com", userID: USER_ID }), + collectionsSince: async (): Promise<CollectionsPage> => ({ + collections: [...collections], + deleted: [], + cursor: 1, + }), + filesSince: async (args: { + collectionID: number; + }): Promise<FilesPage> => ({ + files: filesByAlbum.get(args.collectionID) ?? [], + deleted: [], + cursor: 1, + }), + }; + + // Photo 3 comes from a stand-in server, encrypted as Ente serves a + // live photo. Any other original is a few bytes naming its photo. + // `calls` counts every fetch, thumbnails included. + const server = cdnSource(new Map([[3, live.body]])); + let calls = 0; + const source: ContentSource = { + original: async (args) => { + calls++; + if (args.file.id === 3) return server.original(args); + const bytes = `original-${args.file.id}`; + writeFileSync(args.destination, bytes); + return { bytesWritten: bytes.length }; + }, + thumbnail: async (args) => { + calls++; + return server.thumbnail(args); + }, + }; + + const dir = join(root, "photos"); + const open = (): Promise<Library> => + Library.open({ + client, + cacheDirectory: join(root, "cache"), + downloadDirectory: dir, + contentSource: source, + refreshIntervalSeconds: 3600, + precacheThumbnails: false, + precacheOriginals: false, + }); + + const first = await open(); + // The cache already holds photo 1's original, so its record names a + // cache path, which the metadata leaves out. download() copies it + // from the cache rather than fetching it again. + await first.photos.byID({ fileID: 1 })!.original(); + expect(await downloadAlbums(first, dir)).toEqual({ + downloaded: 3, + alreadyLocal: 0, + }); + await first.close(); + expect(calls).toBe(3); + + // Each original at its save path, a live photo as its image, its video + // and the file naming them, and each photo's metadata beside it. + const day = join(dir, DAY); + expect(readdirSync(day).sort()).toEqual([ + "2026-03-01.1.jpg", + "2026-03-01.1.jpg.json", + "2026-03-01.2.jpg", + "2026-03-01.2.jpg.json", + "2026-03-01.3.heic", + "2026-03-01.3.heic.json", + "2026-03-01.3.livephoto.json", + "2026-03-01.3.mov", + ]); + expect(readFileSync(join(day, "2026-03-01.1.jpg"), "utf-8")).toBe( + "original-1", + ); + expect(readFileSync(join(day, "2026-03-01.2.jpg"), "utf-8")).toBe( + "original-2", + ); + expect(readFileSync(join(day, "2026-03-01.3.heic"))).toEqual( + Buffer.from(HEIC_WITH_EXIF), + ); + expect(readFileSync(join(day, "2026-03-01.3.mov"))).toEqual( + Buffer.from(VIDEO), + ); + + // The metadata is the photo's record and its EXIF fields. The + // originals of photos 1 and 2 are not image data, so they have no + // EXIF fields. Photo 3's image holds a camera, an exposure and a + // position, and the date it was taken is written as an ISO 8601 + // string. `record` holds the fields the three records share. + const record = { + takenAt: TAKEN_MS, + modifiedAt: TAKEN_MS, + fileType: "image", + isArchived: false, + isHidden: false, + }; + expect(readJSON(join(day, "2026-03-01.1.jpg.json"))).toEqual({ + ...record, + fileID: 1, + albumIDs: [1], + title: "file-1.jpg", + exif: {}, + }); + expect(readJSON(join(day, "2026-03-01.2.jpg.json"))).toEqual({ + ...record, + fileID: 2, + albumIDs: [1, 2], + title: "file-2.jpg", + exif: {}, + }); + expect(readJSON(join(day, "2026-03-01.3.heic.json"))).toEqual({ + ...record, + fileID: 3, + albumIDs: [2], + title: "file-3.jpg", + fileType: "livePhoto", + hash: livePhotoHash(HEIC_WITH_EXIF, VIDEO), + exif: { + make: "Canon", + model: "EOS R5", + lensModel: "RF50mm F1.8 STM", + dateTimeOriginal: "2021-07-15T14:30:00.000Z", + offsetTimeOriginal: "+02:00", + exposureTime: 1 / 250, + fNumber: 2.8, + iso: 400, + focalLength: 50, + orientation: 6, + gpsLatitude: 40 + 26 / 60 + 46 / 3600, + gpsLongitude: -(79 + 58 / 60 + 56 / 3600), + gpsAltitude: -12.5, + }, + }); + + // Each album's photos, newest first, by save path relative to `dir`. + // Photo 2 is in both. + expect(readdirSync(join(dir, "albums")).sort()).toEqual([ + "1.json", + "2.json", + ]); + expect(readJSON(join(dir, "albums", "1.json"))).toEqual({ + collectionID: 1, + name: "Trip", + savePaths: [ + join(DAY, "2026-03-01.2.jpg"), + join(DAY, "2026-03-01.1.jpg"), + ], + }); + expect(readJSON(join(dir, "albums", "2.json"))).toEqual({ + collectionID: 2, + name: "Family", + savePaths: [ + join(DAY, "2026-03-01.3.heic"), + join(DAY, "2026-03-01.2.jpg"), + ], + }); + + // Before the second run the account gains album 3, "Garden", holding + // a new photo 4. The second run, with a newly opened library, opens + // the cache written by the first and still downloads photo 4. It finds + // the other photos already local and fetches nothing else. + collections.push(collection(3, "Garden")); + filesByAlbum.set(3, [file(4, 3)]); + backdate(dir); + const before = mtimes(dir); + const second = await open(); + expect(await downloadAlbums(second, dir)).toEqual({ + downloaded: 1, + alreadyLocal: 3, + }); + await second.close(); + expect(calls).toBe(4); + expect(readFileSync(join(day, "2026-03-01.4.jpg"), "utf-8")).toBe( + "original-4", + ); + expect(readJSON(join(dir, "albums", "3.json"))).toEqual({ + collectionID: 3, + name: "Garden", + savePaths: [join(DAY, "2026-03-01.4.jpg")], + }); + + // The second run adds only photo 4's files and album 3's, and + // rewrites, renames or removes no file from the first run. The two + // directories that gain a file are the only other changes. + const after = mtimes(dir); + expect( + [...after.keys()].filter((name) => !before.has(name)).sort(), + ).toEqual([ + join(DAY, "2026-03-01.4.jpg"), + join(DAY, "2026-03-01.4.jpg.json"), + join("albums", "3.json"), + ]); + for (const [name, mtime] of before) { + if (name === DAY || name === "albums") continue; + expect(after.get(name), name).toBe(mtime); + } + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 1cab40d..4d5ce89 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,5 +18,5 @@ "sourceMap": true, "resolveJsonModule": true }, - "include": ["src/**/*", "bin/**/*"] + "include": ["src/**/*", "bin/**/*", "examples/**/*"] } From d788c5457d8d9f0a891f08c87f24a8694b2b2883 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 03:49:23 +0200 Subject: [PATCH 09/39] Thumbnail test: re-encode a small image so it cannot time out (closes #153) The test "re-encodes smaller until the thumbnail fits the recorded size" built a noisy 400x300 JPEG and encoded it several times. That came close to vitest's 5 s limit and timed out on a busy host, which made `script/cibuild` fail on `next` and `main`. It now uses a noisy 64x48 JPEG, still too big for the first, default-quality encoding to fit, and keeps every assertion. It runs in about 1 s. Model: opus-5-5 --- test/thumbnails/thumbnails.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/thumbnails/thumbnails.test.ts b/test/thumbnails/thumbnails.test.ts index 580a312..4acd89b 100644 --- a/test/thumbnails/thumbnails.test.ts +++ b/test/thumbnails/thumbnails.test.ts @@ -787,12 +787,13 @@ describe("fixMissingThumbnails", () => { }); it("re-encodes smaller until the thumbnail fits the recorded size", async () => { - // A noisy 400x300 JPEG, which the default encoding (quality 50, not + // A noisy 64x48 JPEG, which the default encoding (quality 50, not // resized because it is under 720 px) cannot compress below the size - // recorded here: one byte less than that encoding's ciphertext. + // recorded here: one byte less than that encoding's ciphertext. It is + // small so that each encode is quick even on a busy host. const fixMock = await buildThumbMock(); - const w = 400; - const h = 300; + const w = 64; + const h = 48; const noisy = new Uint8Array( jpegJs.encode( { From 0c995a8c4f133a0bc56315964777d71565f94845 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 04:14:30 +0200 Subject: [PATCH 10/39] Remove the content cache's handling of an earlier version's live-photo ZIP (closes #151) quak is pre-1.0 and keeps no handling of old data. The content cache no longer recognises or removes a live photo that an earlier quak version cached as one ZIP. A live-photo download no longer removes what was at its destination before renaming the image and video into place; the rename already replaces it. The README sentences and the tests about that old ZIP are gone. The two removed tests that also covered current behaviour are replaced by tests with no ZIP: the cache re-fetching a live photo recorded with no video, and the library telling the cache which files are live photos when it opens. Model: opus-5-5 --- README.md | 5 +-- TODO.md | 4 ++ src/download/index.ts | 8 ++-- src/library/content.ts | 46 ++++--------------- test/download/download.test.ts | 9 ---- test/library/content-library.test.ts | 20 +++++++-- test/library/content.test.ts | 67 +++++++++++++--------------- 7 files changed, 63 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index 12c99e2..2c8b6a7 100644 --- a/README.md +++ b/README.md @@ -879,10 +879,7 @@ current account's records name. A live photo's original is cached as at its save path: its image and its video, each `originals/<fileID>.<ext>` with its own extension, and -`originals/<fileID>.livephoto.json` naming them; the two are evicted together. A -live photo that an earlier version cached as its ZIP is not served: the library -removes the ZIP when it opens the cache, and fetches the two files when the -photo is next read or precached. +`originals/<fileID>.livephoto.json` naming them; the two are evicted together. A stored file appears only via an atomic temp-then-rename, so its presence means it is complete. Every downloaded original (by `quak get`, the cache, or diff --git a/TODO.md b/TODO.md index 29cfd56..f37e1cb 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,10 @@ declares one. # Completed Steps +- 2026-10-01: The content cache no longer looks for a live photo that an earlier + version cached as one ZIP (issue 151). When the cache opens, a live photo's + file that no JSON file names is now always left alone. + - 2026-10-01: `examples/download-albums.ts` logs in, opens the library, and for every album downloads each photo to its save path, writes the photo's record and EXIF fields to a JSON file beside it, and writes the album's photos to diff --git a/src/download/index.ts b/src/download/index.ts index 7729890..e27184b 100644 --- a/src/download/index.ts +++ b/src/download/index.ts @@ -361,8 +361,8 @@ const openPart = async ( // written unpacked: each part is named `destination` with the extension // replaced by its own entry's, and the two must differ ignoring case. When the // file records a hash, `<imageHash>:<videoHash>` must match it, each over that -// part's own bytes. Only then is whatever was at `destination` removed and the -// image, then the video, renamed into place; on any failure neither is stored. +// 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 // written as it decompresses and never held, and the ZIP is refused once the @@ -486,7 +486,6 @@ const decryptLivePhoto = async ( await part.handle.sync(); await part.handle.close(); } - await rm(destination, { force: true }); await rename(image.tmpPath, path); try { await rename(video.tmpPath, videoPath); @@ -570,8 +569,7 @@ const fetchAndDecrypt = async ( }, api.getRetryOptions()); // Write `file`'s original to `outPath`. A live photo is written as its image -// and its video beside `outPath` instead, and whatever was at `outPath` is -// removed (see `decryptLivePhoto`). +// and its video beside `outPath` instead (see `decryptLivePhoto`). export const downloadFile = async ( api: ApiClient, file: EnteFile, diff --git a/src/library/content.ts b/src/library/content.ts index 396fdb8..383319b 100644 --- a/src/library/content.ts +++ b/src/library/content.ts @@ -29,14 +29,7 @@ // the cache does not count as saved there, but is copied there rather than // fetched again. -import { - closeSync, - existsSync, - openSync, - readFileSync, - readSync, - statSync, -} from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; import { chmod, copyFile, @@ -281,25 +274,6 @@ const fileSize = (path: string): number | undefined => { const hasContent = (path: string | undefined): boolean => path !== undefined && (fileSize(path) ?? 0) > 0; -// Whether the file at `path` begins as a ZIP does, with `PK\x03\x04`. False -// when it cannot be read. -const isZip = (path: string): boolean => { - try { - const fd = openSync(path, "r"); - try { - const head = Buffer.alloc(4); - return ( - readSync(fd, head, 0, 4, 0) === 4 && - head.toString("latin1") === "PK\x03\x04" - ); - } finally { - closeSync(fd); - } - } catch { - return false; - } -}; - // A live photo's image and video are named with the extensions from inside its // ZIP, so their names alone do not say which is which. Wherever the cache or a // save path stores one, a JSON file of this name beside them names both. @@ -713,8 +687,10 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI { await this.touch(cached.path); return { ...cached, bytes: size, cached: true }; } - // A recorded file that has since gone, or a live photo an earlier - // version stored as one ZIP, re-fetches below. + // A recorded file that has since gone re-fetches below. So does a + // live photo recorded with no video: the cache opened before the + // library's records said it is a live photo, while its image and + // video had no JSON file beside them yet. known.delete(fileID); } @@ -983,20 +959,14 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI { if (id === undefined || !existsSync(path)) continue; // A live photo's image and video are one entry, as the JSON file // beside them names them. A live photo's file with no such JSON - // file is not its original. If it is a ZIP, it is the one an - // earlier version stored under the image's name, and is removed. - // Any other is left alone: another process may have just stored - // it and not yet written the JSON file. + // file is not its original and is left alone: another process may + // have just stored it and not yet written the JSON file. const livePhoto = names.has(livePhotoJSONName(String(id))) ? readLivePhotoJSON(dir, String(id)) : undefined; if (livePhoto !== undefined) { into.set(id, livePhoto); - } else if (isLivePhoto(id)) { - if (isZip(path)) { - await rm(path, { force: true }).catch(() => undefined); - } - } else { + } else if (!isLivePhoto(id)) { into.set(id, { path }); } } diff --git a/test/download/download.test.ts b/test/download/download.test.ts index e08a948..06788fd 100644 --- a/test/download/download.test.ts +++ b/test/download/download.test.ts @@ -1883,15 +1883,6 @@ describe("downloadFile live photos", () => { expect(readdirSync(t.dir).sort()).toEqual(["f.JPG", "f.bin"]); }); - it("replaces what was at the destination, such as an earlier ZIP of the two", async () => { - const t = setup(livePhotoZip(), livePhoto); - writeFileSync(t.outPath, livePhotoZip()); - - await t.run(); - - expect(readdirSync(t.dir).sort()).toEqual(["f.heic", "f.mov"]); - }); - it("renames the image and then the video into place, each from its own temp file", async () => { const t = setup(livePhotoZip(), livePhoto); diff --git a/test/library/content-library.test.ts b/test/library/content-library.test.ts index ffa0cc7..d5976a4 100644 --- a/test/library/content-library.test.ts +++ b/test/library/content-library.test.ts @@ -176,7 +176,7 @@ describe("Library content wiring", () => { await lib.close(); }); - it("removes a live photo's ZIP an earlier version cached when it opens, and precaches its image and video", async () => { + it("does not take a live photo's image or video with no JSON file as its original when it opens, and precaches both", async () => { const { file: live, body } = await asLivePhoto(file(1, 1)); class LiveClient extends MockClient { override async filesSince(): Promise<FilesPage> { @@ -197,7 +197,8 @@ describe("Library content wiring", () => { // A first run records the library, so the next one knows that file 1 // is a live photo when it opens the cache. await (await open({})).close(); - writeFileSync(join(originals, "1.jpg"), livePhotoZip()); + writeFileSync(join(originals, "1.heic"), "an image"); + writeFileSync(join(originals, "1.mov"), "a video"); let precached!: () => void; const done = new Promise<void>((r) => (precached = r)); @@ -208,7 +209,9 @@ describe("Library content wiring", () => { precached(); }, }); - expect(existsSync(join(originals, "1.jpg"))).toBe(false); + expect( + lib.photos.byID({ fileID: 1 })!.record().originalPath, + ).toBeUndefined(); await done; expect(readdirSync(originals).sort()).toEqual([ @@ -216,6 +219,17 @@ describe("Library content wiring", () => { "1.livephoto.json", "1.mov", ]); + expect(readFileSync(join(originals, "1.heic"))).toEqual( + Buffer.from(IMAGE), + ); + expect(readFileSync(join(originals, "1.mov"))).toEqual( + Buffer.from(VIDEO), + ); + expect( + JSON.parse( + readFileSync(join(originals, "1.livephoto.json"), "utf-8"), + ), + ).toEqual({ image: "1.heic", video: "1.mov" }); expect(lib.photos.byID({ fileID: 1 })!.record().originalPath).toBe( join(originals, "1.heic"), ); diff --git a/test/library/content.test.ts b/test/library/content.test.ts index f209359..b461742 100644 --- a/test/library/content.test.ts +++ b/test/library/content.test.ts @@ -566,43 +566,6 @@ describe("ContentCache live photos", () => { expect(events).toEqual(["skipped"]); }); - it("replaces a live photo an earlier version stored as a ZIP under the image's name", async () => { - const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC")); - mkdirSync(originals(), { recursive: true }); - writeFileSync(join(originals(), "5.HEIC"), livePhotoZip()); - const cache = cacheOf([live], new Map([[5, body]])); - // Opened without being told that file 5 is a live photo, the cache - // records the ZIP, and does not serve it. - await cache.open(); - - const result = await cache.original(5); - - expect(result.videoPath).toBe(join(originals(), "5.mov")); - expect(readdirSync(originals()).sort()).toEqual([ - "5.heic", - "5.livephoto.json", - "5.mov", - ]); - }); - - it("removes a live photo's ZIP an earlier version stored when it opens, so the precache fetches the image and video", async () => { - const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC")); - mkdirSync(originals(), { recursive: true }); - writeFileSync(join(originals(), "5.HEIC"), livePhotoZip()); - const cache = cacheOf([live], new Map([[5, body]])); - - await cache.open((fileID) => fileID === 5); - - expect(readdirSync(originals())).toEqual([]); - expect(cache.pathsFor(5)).toEqual({}); - const [fetched] = await cache.ensureOriginals({ fileIDs: [5] }); - expect(fetched).toEqual({ - fileID: 5, - path: join(originals(), "5.heic"), - }); - expect(cache.pathsFor(5)).toEqual({ originalPath: fetched!.path }); - }); - it("leaves the image and video another process has just stored when it opens before their JSON file is written", async () => { const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC")); const server = cdnSource(new Map([[5, body]])); @@ -633,6 +596,36 @@ describe("ContentCache live photos", () => { expect(second!.pathsFor(5)).toEqual({}); }); + it("fetches a live photo's image and video again when the cache opened before knowing it is a live photo and no JSON file names them", async () => { + const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC")); + mkdirSync(originals(), { recursive: true }); + writeFileSync(join(originals(), "5.heic"), "an image"); + writeFileSync(join(originals(), "5.mov"), "a video"); + const cache = cacheOf([live], new Map([[5, body]])); + // Opened without being told that file 5 is a live photo, the cache + // records one of the two files as its original, with no video. + await cache.open(); + + const events: string[] = []; + const result = await cache.original(5, { + onProgress: (e) => events.push(e.status), + }); + + expect(events.at(-1)).toBe("done"); + expect(result).toEqual({ + path: join(originals(), "5.heic"), + videoPath: join(originals(), "5.mov"), + bytes: IMAGE.length, + }); + expect(readFileSync(result.path)).toEqual(Buffer.from(IMAGE)); + expect(readFileSync(result.videoPath!)).toEqual(Buffer.from(VIDEO)); + expect( + JSON.parse( + readFileSync(join(originals(), "5.livephoto.json"), "utf-8"), + ), + ).toEqual({ image: "5.heic", video: "5.mov" }); + }); + it.each(["missing", "empty"])( "fetches a live photo again when the video its JSON file names is %s", async (state) => { From e50d2a78c8b4e917d3158af43923d53d6d22cb3a Mon Sep 17 00:00:00 2001 From: clawbot <sneak+clawbot@sneak.cloud> Date: Fri, 2 Oct 2026 05:30:05 +0200 Subject: [PATCH 11/39] exif() returns every EXIF tag in the file (closes #156) `photo.exif()` now returns every EXIF tag in the file, as the owner ruled, typed `ExifTags`: each tag keyed by name with `exifreader`'s `id`, `value`, `description` and `computed`. A tag with no name is keyed `undefined-` plus its number, and the embedded thumbnail's tags sit under `Thumbnail`. The thirteen typed methods stay, with the same names and types; each now picks its field from `exif()`'s tags. GPS latitude and longitude are worked out from the GPS tags and their reference tags, and a position with no reference tags gives neither. `backup-metadata --exif` output is unchanged. Model: opus-5-5 Co-authored-by: clawbot <sneak+clawbot@sneak.cloud> --- README.md | 33 +++--- TODO.md | 7 ++ src/exif.ts | 111 ++++++++++++++------ src/index.ts | 2 +- src/library/read.ts | 56 +++++----- test/cli/metadata-exif.test.ts | 146 ++++++++++++++++++++++++-- test/examples/download-albums.test.ts | 26 ++--- test/library/content-library.test.ts | 111 +++++++++++++++----- 8 files changed, 368 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index 2c8b6a7..97b703e 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,8 @@ requires one, and writes: photo its image, its video and the `.livephoto.json` file naming them - beside each original, a JSON file named after it with `.json` added, for example `2026-03-01.12345.jpg.json`: the photo's record (`photo.record()`) - without its cache paths, and its EXIF fields (`photo.exif()`) under `exif` + without its cache paths, and every EXIF tag of the photo (`photo.exif()`) + under `exif` - `albums/<collectionID>.json` for each album: its `collectionID`, its `name`, and under `savePaths` the save paths of its photos relative to `dir`, newest first @@ -773,21 +774,25 @@ These async methods may download: - `await photo.thumbnail(opts?)` → `{ path, bytes }`. - `await photo.content(opts?)` → `Uint8Array` — the original's bytes, read through `original()`; for a live photo, its image's. -- `await photo.exif(opts?)` → `PhotoExif` — `make`, `model`, `lensModel`, - `dateTimeOriginal`, `offsetTimeOriginal`, `exposureTime`, `fNumber`, `iso`, - `focalLength`, `orientation`, `gpsLatitude`, `gpsLongitude` and `gpsAltitude`, - each absent when the file lacks it. GPS values are signed decimal degrees and - metres. `dateTimeOriginal` is the camera's clock reading held in the `Date`'s - UTC fields; `offsetTimeOriginal`, when present, is that clock's offset from - UTC. EXIF is read from any image format exifreader reads (such as JPEG, - HEIC/HEIF, AVIF, PNG, WebP and TIFF), a live photo's image included. Any other - original gives `{}`, and a video gives `{}` without being downloaded. +- `await photo.exif(opts?)` → `ExifTags` — every EXIF tag in the file, keyed by + tag name, each as exifreader decodes it, with its `id`, `value`, `description` + and `computed` value: for example `Make` is + `{ id: 271, value: ["Canon"], description: "Canon", computed: "Canon" }`. A + tag exifreader has no name for is keyed `undefined-<tag number>`. The embedded + thumbnail's tags are under `Thumbnail`, so they cannot hide the main image's + tags of the same name; the thumbnail image itself is left out. EXIF is read + from any image format exifreader reads (such as JPEG, HEIC/HEIF, AVIF, PNG, + WebP and TIFF), a live photo's image included. Any other original gives `{}`, + and a video gives `{}` without being downloaded. - `await photo.make(opts?)`, and likewise `model()`, `lensModel()`, `dateTimeOriginal()`, `offsetTimeOriginal()`, `exposureTime()`, `fNumber()`, `iso()`, `focalLength()`, `orientation()`, `gpsLatitude()`, `gpsLongitude()` - and `gpsAltitude()` → one field of `exif()` each, typed as in `PhotoExif`, or - `undefined` when the file lacks it. Each calls `exif()` with its `opts`, so - each call reads the original again. + and `gpsAltitude()` → one common field each, picked from the tags `exif()` + returns and typed as in `PhotoExif`, or `undefined` when the file lacks it. + GPS values are signed decimal degrees and metres. `dateTimeOriginal()` is the + camera's clock reading held in the `Date`'s UTC fields; + `offsetTimeOriginal()`, when present, is that clock's offset from UTC. Each + calls `exif()` with its `opts`, so each call reads the original again. They serve from the on-disk content cache when the bytes are present and otherwise fetch through the pools; `original()`, `content()` and `exif()` also @@ -904,7 +909,7 @@ from a very old client, is stored unchecked. - `src/library/records.ts`: `PhotoRecord`, `AlbumRecord`, `LibrarySnapshot`, `LibraryChange` - `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult` -- `src/exif.ts`: `PhotoExif` +- `src/exif.ts`: `ExifTags`, `PhotoExif` - `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool` - `src/backup.ts`: `BackupOptions`, `BackupResult`, `BackupError` - `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot` diff --git a/TODO.md b/TODO.md index f37e1cb..d6972af 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,13 @@ declares one. # Completed Steps +- 2026-10-02: `photo.exif()` returns every EXIF tag in the file as `ExifTags`, + keyed by tag name, each as exifreader decodes it, not only the thirteen common + fields (issue 156). The embedded thumbnail's tags are under `Thumbnail`, + without the thumbnail image. The thirteen typed methods stay, each picking its + field from the tags `exif()` returns, typed as in `PhotoExif`. The example + script's JSON files now carry every tag. + - 2026-10-01: The content cache no longer looks for a live photo that an earlier version cached as one ZIP (issue 151). When the cache opens, a live photo's file that no JSON file names is now always left alone. diff --git a/src/exif.ts b/src/exif.ts index b0f13f5..3819cd8 100644 --- a/src/exif.ts +++ b/src/exif.ts @@ -1,19 +1,19 @@ // EXIF in an original's bytes, read with exifreader, which reads it from JPEG, // HEIC/HEIF, AVIF, PNG, WebP and the other image formats it supports. // `backup-metadata --exif` records every EXIF tag it finds except the -// thumbnail's; `Photo.exif()` returns the common fields picked from them here. +// thumbnail's. `Photo.exif()` returns every tag, the thumbnail's included, and +// `Photo`'s typed methods return the common fields picked from them here. import ExifReader, { type ExpandedTags } from "exifreader"; -// The EXIF tags in `bytes` (`exif`), the GPS position exifreader computes from -// them (`gps`), and where the EXIF block lies in `bytes` (`metadataRange`). +// The EXIF tags in `bytes` (`exif`), the embedded thumbnail's tags +// (`Thumbnail`), and where the EXIF block lies in `bytes` (`metadataRange`). // Undefined when exifreader cannot read the file at all, such as a video. An -// EXIF block it finds but reads no tag from comes back as an empty `exif`. -// `exif` holds every tag except the thumbnail's; a tag exifreader has no name -// for is keyed `undefined-<tag number>`. Each tag's `computed` holds its value -// as a string or number, or as an array of them for a tag with several values, -// such as `GPSLatitude`'s `[40, 26, 46]`. A fraction with a zero denominator -// computes to null. +// EXIF block it finds but reads no tag from comes back as an empty `exif`. A +// tag exifreader has no name for is keyed `undefined-<tag number>`. Each tag's +// `computed` holds its value as a string or number, or as an array of them for +// a tag with several values, such as `GPSLatitude`'s `[40, 26, 46]`. A +// fraction with a zero denominator computes to null. export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => { try { return ExifReader.loadView( @@ -23,7 +23,7 @@ export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => { computed: true, includeOffsets: true, includeUnknown: true, - includeTags: { exif: true, gps: true }, + includeTags: { exif: true, thumbnail: true }, }, ); } catch { @@ -31,7 +31,35 @@ export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => { } }; -// The common EXIF fields of an original. Each is absent when the file lacks it. +// Every EXIF tag of an original, keyed by name, each as exifreader decodes it +// (see `readExifTags`). The embedded thumbnail's own tags are under +// `Thumbnail`, so its `Orientation` or `ImageWidth` cannot hide the main +// image's. +export type ExifTags = Omit<NonNullable<ExpandedTags["exif"]>, "Thumbnail"> & { + Thumbnail?: Omit< + NonNullable<ExpandedTags["Thumbnail"]>, + "type" | "image" | "base64" + >; +}; + +// Every EXIF tag in `bytes`: `{}` when the file has no EXIF, exifreader cannot +// read its EXIF, or it is not an image exifreader reads. +export const readAllExifTags = (bytes: Uint8Array): ExifTags => { + const tags = readExifTags(bytes); + if (!tags?.Thumbnail) return tags?.exif ?? {}; + // exifreader puts the thumbnail's JPEG image beside its tags, as `type`, + // `image` and `base64`. The image is not a tag, so it is left out. + const { + type: _type, + image: _image, + base64: _base64, + ...thumbnail + } = tags.Thumbnail; + return { ...tags.exif, Thumbnail: thumbnail }; +}; + +// The common EXIF fields of an original, one for each of `Photo`'s typed +// methods. Each is absent when the file lacks it. export interface PhotoExif { make?: string; model?: string; @@ -80,30 +108,51 @@ const asDate = (v: unknown): Date | undefined => { return Number.isNaN(date.getTime()) ? undefined : date; }; -// The common fields of an original's EXIF: `{}` when the file has no EXIF, -// exifreader cannot read its EXIF, or it is not an image exifreader reads. -export const readPhotoExif = (bytes: Uint8Array): PhotoExif => { - const tags = readExifTags(bytes); - const exif = tags?.exif; - const gps = tags?.gps; - const altitude = asNumber(exif?.GPSAltitude?.computed); +// GPSLatitude and GPSLongitude hold degrees, minutes and seconds, computed as +// three numbers. This is them in decimal degrees, negative when `ref`, the +// GPSLatitudeRef or GPSLongitudeRef tag, is `negativeRef` ("S" or "W"). +// Without that tag the hemisphere is unknown, so it is undefined. +const asDegrees = ( + dms: unknown, + ref: unknown, + negativeRef: string, +): number | undefined => { + if (!Array.isArray(dms) || ref === undefined) return undefined; + const [d, m, s] = dms.map(asNumber); + if (d === undefined || m === undefined || s === undefined) return undefined; + const degrees = d + m / 60 + s / 3600; + return ref === negativeRef ? -degrees : degrees; +}; + +// The common fields picked from an original's EXIF tags, `readAllExifTags`'s +// result: `{}` when there are none. +export const readPhotoExif = (tags: ExifTags): PhotoExif => { + const altitude = asNumber(tags.GPSAltitude?.computed); const fields: PhotoExif = { - make: asString(exif?.Make?.computed), - model: asString(exif?.Model?.computed), - lensModel: asString(exif?.LensModel?.computed), - dateTimeOriginal: asDate(exif?.DateTimeOriginal?.computed), - offsetTimeOriginal: asString(exif?.OffsetTimeOriginal?.computed), - exposureTime: asNumber(exif?.ExposureTime?.computed), - fNumber: asNumber(exif?.FNumber?.computed), + make: asString(tags.Make?.computed), + model: asString(tags.Model?.computed), + lensModel: asString(tags.LensModel?.computed), + dateTimeOriginal: asDate(tags.DateTimeOriginal?.computed), + offsetTimeOriginal: asString(tags.OffsetTimeOriginal?.computed), + exposureTime: asNumber(tags.ExposureTime?.computed), + fNumber: asNumber(tags.FNumber?.computed), // Only when the tag holds a single number, as most cameras write it. - iso: asNumber(exif?.ISOSpeedRatings?.computed), - focalLength: asNumber(exif?.FocalLength?.computed), - orientation: asNumber(exif?.Orientation?.computed), - gpsLatitude: asNumber(gps?.Latitude), - gpsLongitude: asNumber(gps?.Longitude), + iso: asNumber(tags.ISOSpeedRatings?.computed), + focalLength: asNumber(tags.FocalLength?.computed), + orientation: asNumber(tags.Orientation?.computed), + gpsLatitude: asDegrees( + tags.GPSLatitude?.computed, + tags.GPSLatitudeRef?.computed, + "S", + ), + gpsLongitude: asDegrees( + tags.GPSLongitude?.computed, + tags.GPSLongitudeRef?.computed, + "W", + ), // A GPSAltitudeRef of 1 means the altitude is below sea level. gpsAltitude: - altitude !== undefined && exif?.GPSAltitudeRef?.value === 1 + altitude !== undefined && tags.GPSAltitudeRef?.value === 1 ? -altitude : altitude, }; diff --git a/src/index.ts b/src/index.ts index d237d91..1e8b631 100644 --- a/src/index.ts +++ b/src/index.ts @@ -85,7 +85,7 @@ export type { LibrarySnapshot, LibraryChange, } from "./library/records.js"; -export type { PhotoExif } from "./exif.js"; +export type { ExifTags, PhotoExif } from "./exif.js"; export { decryptCollection, decryptFile } from "./model/index.js"; export { downloadFile, downloadThumbnail } from "./download/index.js"; export type { diff --git a/src/library/read.ts b/src/library/read.ts index 3aac9ce..b676b05 100644 --- a/src/library/read.ts +++ b/src/library/read.ts @@ -13,14 +13,19 @@ // // A `Photo` also fetches its own bytes: `original()`, `thumbnail()`, // `download()`, `content()`, `exif()` and the methods that each return one -// field of `exif()` go through the on-disk content cache (issue #46), and are +// EXIF field go through the on-disk content cache (issue #46), and are // the one place in this module that may touch the network. A library opened // without a content source leaves that cache absent, and those methods then // throw. `savePath` and `isLocal` look only at the disk and need no cache. import { readFile } from "node:fs/promises"; -import { readPhotoExif, type PhotoExif } from "../exif.js"; +import { + readAllExifTags, + readPhotoExif, + type ExifTags, + type PhotoExif, +} from "../exif.js"; import type { CollectionType, EnteFile, FileType } from "../model/types.js"; import type { ContentOptions, ContentResult, PhotoContent } from "./content.js"; import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js"; @@ -156,74 +161,75 @@ export class Photo implements PhotoExifMethods { return readFile(path); } - // The common EXIF fields of the original, read from `content()`, so this - // may download it. EXIF is read from any image format exifreader reads, - // JPEG and HEIC/HEIF among them; any other file gives `{}`, and a video - // gives it without fetching anything. Like the other content methods, it - // throws when there is no content cache, video or not. - async exif(opts?: ContentOptions): Promise<PhotoExif> { + // Every EXIF tag of the original, keyed by name (see `ExifTags`), read + // from `content()`, so this may download it. EXIF is read from any image + // format exifreader reads, JPEG and HEIC/HEIF among them; any other file + // gives `{}`, and a video gives it without fetching anything. Like the + // other content methods, it throws when there is no content cache, video + // or not. + async exif(opts?: ContentOptions): Promise<ExifTags> { this.cacheOrThrow(); if (this.rec.fileType === "video") return {}; - return readPhotoExif(await this.content(opts)); + return readAllExifTags(await this.content(opts)); } - // One field of `exif()` each, named and typed as in `PhotoExif`, and - // undefined when the file lacks it. Each call runs `exif()`, which reads - // the original again. + // One field each, named and typed as in `PhotoExif`, picked from the tags + // `exif()` returns, and undefined when the file lacks it. Each call runs + // `exif()`, which reads the original again. async make(opts?: ContentOptions): Promise<PhotoExif["make"]> { - return (await this.exif(opts)).make; + return readPhotoExif(await this.exif(opts)).make; } async model(opts?: ContentOptions): Promise<PhotoExif["model"]> { - return (await this.exif(opts)).model; + return readPhotoExif(await this.exif(opts)).model; } async lensModel(opts?: ContentOptions): Promise<PhotoExif["lensModel"]> { - return (await this.exif(opts)).lensModel; + return readPhotoExif(await this.exif(opts)).lensModel; } async dateTimeOriginal( opts?: ContentOptions, ): Promise<PhotoExif["dateTimeOriginal"]> { - return (await this.exif(opts)).dateTimeOriginal; + return readPhotoExif(await this.exif(opts)).dateTimeOriginal; } async offsetTimeOriginal( opts?: ContentOptions, ): Promise<PhotoExif["offsetTimeOriginal"]> { - return (await this.exif(opts)).offsetTimeOriginal; + return readPhotoExif(await this.exif(opts)).offsetTimeOriginal; } async exposureTime( opts?: ContentOptions, ): Promise<PhotoExif["exposureTime"]> { - return (await this.exif(opts)).exposureTime; + return readPhotoExif(await this.exif(opts)).exposureTime; } async fNumber(opts?: ContentOptions): Promise<PhotoExif["fNumber"]> { - return (await this.exif(opts)).fNumber; + return readPhotoExif(await this.exif(opts)).fNumber; } async iso(opts?: ContentOptions): Promise<PhotoExif["iso"]> { - return (await this.exif(opts)).iso; + return readPhotoExif(await this.exif(opts)).iso; } async focalLength( opts?: ContentOptions, ): Promise<PhotoExif["focalLength"]> { - return (await this.exif(opts)).focalLength; + return readPhotoExif(await this.exif(opts)).focalLength; } async orientation( opts?: ContentOptions, ): Promise<PhotoExif["orientation"]> { - return (await this.exif(opts)).orientation; + return readPhotoExif(await this.exif(opts)).orientation; } async gpsLatitude( opts?: ContentOptions, ): Promise<PhotoExif["gpsLatitude"]> { - return (await this.exif(opts)).gpsLatitude; + return readPhotoExif(await this.exif(opts)).gpsLatitude; } async gpsLongitude( opts?: ContentOptions, ): Promise<PhotoExif["gpsLongitude"]> { - return (await this.exif(opts)).gpsLongitude; + return readPhotoExif(await this.exif(opts)).gpsLongitude; } async gpsAltitude( opts?: ContentOptions, ): Promise<PhotoExif["gpsAltitude"]> { - return (await this.exif(opts)).gpsAltitude; + return readPhotoExif(await this.exif(opts)).gpsAltitude; } private cacheOrThrow(): PhotoContent { diff --git a/test/cli/metadata-exif.test.ts b/test/cli/metadata-exif.test.ts index 273181c..509f1d8 100644 --- a/test/cli/metadata-exif.test.ts +++ b/test/cli/metadata-exif.test.ts @@ -3,14 +3,18 @@ * `quak backup-metadata --exif` records. * * The originals come from users' libraries, so a truncated or corrupt file - * must neither hang the read nor throw out of it: `readPhotoExif` gives `{}`, + * must neither hang the read nor throw out of it: `readAllExifTags` gives `{}`, * and `backup-metadata` tells an EXIF block it cannot read apart from a file * that simply has no EXIF, carrying the reason in `exifError`. Each JPEG below * is a short hand-built byte array; the HEIC is a real file. */ import { describe, expect, it } from "vitest"; -import { readPhotoExif } from "../../src/exif.js"; +import { + readAllExifTags, + readExifTags, + readPhotoExif, +} from "../../src/exif.js"; import { extractImageMetadata } from "../../src/metadata-backup.js"; import { HEIC_WITH_EXIF } from "../exif-heic.js"; @@ -63,6 +67,51 @@ const TIFF_ALTITUDE_WITHOUT_REF = [ ...[0x00, 0x00, 0x00, 0x19, 0x00, 0x00, 0x00, 0x02], // 25/2, at 44 ]; +// A big-endian TIFF block holding a GPSLatitude of 33° 30' 0" and a +// GPSLatitudeRef of "S". +const TIFF_SOUTHERN_LATITUDE = [ + ...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8 + // The first IFD, at 8: one entry, then no next IFD. + ...[0x00, 0x01], + // The GPS IFD's offset (0x8825), LONG, 26. + ...[0x88, 0x25, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1a], + ...[0x00, 0x00, 0x00, 0x00], + // The GPS IFD, at 26: two entries, then no next IFD. + ...[0x00, 0x02], + // GPSLatitudeRef (0x0001), 2 ASCII bytes: "S". + ...[0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x53, 0x00, 0x00, 0x00], + // GPSLatitude (0x0002), three RATIONALs at 56. + ...[0x00, 0x02, 0x00, 0x05, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x38], + ...[0x00, 0x00, 0x00, 0x00], + ...[0x00, 0x00, 0x00, 0x21, 0x00, 0x00, 0x00, 0x01], // 33/1, at 56 + ...[0x00, 0x00, 0x00, 0x1e, 0x00, 0x00, 0x00, 0x01], // 30/1 + ...[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01], // 0/1 +]; + +// A big-endian TIFF block holding a GPSLatitude of 40° 26' 46" and a +// GPSLongitude of 79° 58' 56", and neither GPSLatitudeRef nor GPSLongitudeRef. +const TIFF_POSITION_WITHOUT_REFS = [ + ...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8 + // The first IFD, at 8: one entry, then no next IFD. + ...[0x00, 0x01], + // The GPS IFD's offset (0x8825), LONG, 26. + ...[0x88, 0x25, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1a], + ...[0x00, 0x00, 0x00, 0x00], + // The GPS IFD, at 26: two entries, then no next IFD. + ...[0x00, 0x02], + // GPSLatitude (0x0002), three RATIONALs at 56. + ...[0x00, 0x02, 0x00, 0x05, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x38], + // GPSLongitude (0x0004), three RATIONALs at 80. + ...[0x00, 0x04, 0x00, 0x05, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x50], + ...[0x00, 0x00, 0x00, 0x00], + ...[0x00, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00, 0x01], // 40/1, at 56 + ...[0x00, 0x00, 0x00, 0x1a, 0x00, 0x00, 0x00, 0x01], // 26/1 + ...[0x00, 0x00, 0x00, 0x2e, 0x00, 0x00, 0x00, 0x01], // 46/1 + ...[0x00, 0x00, 0x00, 0x4f, 0x00, 0x00, 0x00, 0x01], // 79/1, at 80 + ...[0x00, 0x00, 0x00, 0x3a, 0x00, 0x00, 0x00, 0x01], // 58/1 + ...[0x00, 0x00, 0x00, 0x38, 0x00, 0x00, 0x00, 0x01], // 56/1 +]; + // A big-endian TIFF block holding Orientation 6 and a Make whose value lies // past the end of the file. const TIFF_MAKE_PAST_END = [ @@ -87,6 +136,27 @@ const TIFF_UNNAMED_TAG = [ ...[0x00, 0x00, 0x00, 0x00], ]; +// A big-endian TIFF block holding Orientation 6, and a thumbnail IFD holding +// its own Orientation 1 and a 4-byte JPEG thumbnail. +const TIFF_WITH_THUMBNAIL = [ + ...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8 + // The first IFD, at 8: one entry, then the thumbnail IFD at 26. + ...[0x00, 0x01], + // Orientation (0x0112), SHORT, 6. + ...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00], + ...[0x00, 0x00, 0x00, 0x1a], + // The thumbnail IFD, at 26: three entries, then no next IFD. + ...[0x00, 0x03], + // Orientation (0x0112), SHORT, 1. + ...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00], + // JPEGInterchangeFormat (0x0201), LONG: the thumbnail is at 68. + ...[0x02, 0x01, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x44], + // JPEGInterchangeFormatLength (0x0202), LONG: 4 bytes. + ...[0x02, 0x02, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04], + ...[0x00, 0x00, 0x00, 0x00], + ...[0xff, 0xd8, 0xff, 0xd9], // the thumbnail, at 68: an empty JPEG +]; + // An APP1 segment whose length field matches its data. const app1 = (data: number[]): number[] => { const len = data.length + 2; @@ -96,31 +166,90 @@ const app1 = (data: number[]): number[] => { const bytes = (...parts: number[][]): Uint8Array => new Uint8Array(parts.flat()); +describe("readAllExifTags", () => { + it("keys a tag exifreader has no name for by its number", () => { + const data = [...EXIF_HEADER, ...TIFF_UNNAMED_TAG]; + expect(readAllExifTags(bytes(SOI, app1(data), SOS))).toStrictEqual({ + "undefined-49152": { + id: 49152, + value: 7, + description: 7, + computed: 7, + }, + }); + }); + + it("puts the thumbnail's tags under Thumbnail, without its image", () => { + const input = bytes( + SOI, + app1([...EXIF_HEADER, ...TIFF_WITH_THUMBNAIL]), + SOS, + ); + // exifreader finds the thumbnail's image. + expect(readExifTags(input)?.Thumbnail?.type).toBe("image/jpeg"); + const tags = readAllExifTags(input); + expect(tags.Orientation?.value).toBe(6); + expect(Object.keys(tags.Thumbnail ?? {}).sort()).toEqual([ + "JPEGInterchangeFormat", + "JPEGInterchangeFormatLength", + "Orientation", + ]); + expect(tags.Thumbnail?.Orientation?.value).toBe(1); + expect(readPhotoExif(tags)).toStrictEqual({ orientation: 6 }); + }); +}); + describe("readPhotoExif", () => { it("reads the common fields of a valid JPEG", () => { const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6]; - expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ + expect( + readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))), + ).toStrictEqual({ orientation: 6, }); }); it("gives no dateTimeOriginal for a DateTimeOriginal of 0000:00:00 00:00:00", () => { const data = [...EXIF_HEADER, ...TIFF_UNSET_DATE]; - expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ + expect( + readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))), + ).toStrictEqual({ orientation: 6, }); }); it("reads a GPSAltitude without GPSAltitudeRef as above sea level", () => { const data = [...EXIF_HEADER, ...TIFF_ALTITUDE_WITHOUT_REF]; - expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ + expect( + readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))), + ).toStrictEqual({ gpsAltitude: 12.5, }); }); + it("reads a GPSLatitude with GPSLatitudeRef S as south of the equator", () => { + const data = [...EXIF_HEADER, ...TIFF_SOUTHERN_LATITUDE]; + expect( + readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))), + ).toStrictEqual({ + gpsLatitude: -33.5, + }); + }); + + it("gives no gpsLatitude or gpsLongitude without their reference tags", () => { + const data = [...EXIF_HEADER, ...TIFF_POSITION_WITHOUT_REFS]; + const tags = readAllExifTags(bytes(SOI, app1(data), SOS)); + // The position is read; only its hemisphere is unknown. + expect(tags.GPSLatitude?.computed).toStrictEqual([40, 26, 46]); + expect(tags.GPSLongitude?.computed).toStrictEqual([79, 58, 56]); + expect(readPhotoExif(tags)).toStrictEqual({}); + }); + it("gives no make for a Make whose value lies past the end of the file", () => { const data = [...EXIF_HEADER, ...TIFF_MAKE_PAST_END]; - expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ + expect( + readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))), + ).toStrictEqual({ orientation: 6, }); }); @@ -175,8 +304,9 @@ describe("readPhotoExif", () => { "an EXIF block that cannot be parsed", bytes(SOI, app1([...EXIF_HEADER, 0x58, 0x58]), SOS), ], - ])("returns no fields for %s", (_, input) => { - expect(readPhotoExif(input)).toStrictEqual({}); + ])("returns no tags and no fields for %s", (_, input) => { + expect(readAllExifTags(input)).toStrictEqual({}); + expect(readPhotoExif(readAllExifTags(input))).toStrictEqual({}); }); }); diff --git a/test/examples/download-albums.test.ts b/test/examples/download-albums.test.ts index 8607b03..ac130a7 100644 --- a/test/examples/download-albums.test.ts +++ b/test/examples/download-albums.test.ts @@ -29,6 +29,7 @@ import { downloadAlbums } from "../../examples/download-albums.js"; import { Library, type ContentSource } from "../../src/index.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; +import { readAllExifTags } from "../../src/exif.js"; import { HEIC_WITH_EXIF } from "../exif-heic.js"; import { asLivePhoto, @@ -201,11 +202,10 @@ describe("examples/download-albums.ts", () => { Buffer.from(VIDEO), ); - // The metadata is the photo's record and its EXIF fields. The - // originals of photos 1 and 2 are not image data, so they have no - // EXIF fields. Photo 3's image holds a camera, an exposure and a - // position, and the date it was taken is written as an ISO 8601 - // string. `record` holds the fields the three records share. + // The metadata is the photo's record and its EXIF tags. The originals + // of photos 1 and 2 are not image data, so they have no EXIF tags. + // Photo 3's are every tag of its image, as `photo.exif()` returns + // them. `record` holds the fields the three records share. const record = { takenAt: TAKEN_MS, modifiedAt: TAKEN_MS, @@ -234,21 +234,7 @@ describe("examples/download-albums.ts", () => { title: "file-3.jpg", fileType: "livePhoto", hash: livePhotoHash(HEIC_WITH_EXIF, VIDEO), - exif: { - make: "Canon", - model: "EOS R5", - lensModel: "RF50mm F1.8 STM", - dateTimeOriginal: "2021-07-15T14:30:00.000Z", - offsetTimeOriginal: "+02:00", - exposureTime: 1 / 250, - fNumber: 2.8, - iso: 400, - focalLength: 50, - orientation: 6, - gpsLatitude: 40 + 26 / 60 + 46 / 3600, - gpsLongitude: -(79 + 58 / 60 + 56 / 3600), - gpsAltitude: -12.5, - }, + exif: readAllExifTags(HEIC_WITH_EXIF), }); // Each album's photos, newest first, by save path relative to `dir`. diff --git a/test/library/content-library.test.ts b/test/library/content-library.test.ts index d5976a4..a8a139f 100644 --- a/test/library/content-library.test.ts +++ b/test/library/content-library.test.ts @@ -7,7 +7,7 @@ * a cached path shows up on the projected record. A library opened without a * content source leaves those methods throwing rather than silently doing * nothing. It also covers a `Photo`'s `savePath`, `isLocal`, `download()`, - * `content()`, `exif()` and the methods that each return one field of `exif()`. + * `content()`, `exif()` and the methods that each return one EXIF field. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; @@ -27,7 +27,7 @@ import { Library, type LibraryOptions } from "../../src/library/index.js"; import type { ContentSource } from "../../src/library/content.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; -import type { PhotoExif } from "../../src/exif.js"; +import { readPhotoExif, type PhotoExif } from "../../src/exif.js"; import { HEIC_WITH_EXIF } from "../exif-heic.js"; import { asLivePhoto, @@ -273,10 +273,10 @@ const entry = ( value: number[], ): number[] => [...u16(tag), ...u16(type), ...u32(count), ...value]; -// The TIFF block of a JPEG's EXIF segment, holding every field `exif()` picks: -// the camera in the first IFD, the exposure in the Exif IFD, and a GPS position -// of 40°26'46" N, 79°58'56" W, 12.5 m below sea level. Offsets count from the -// start of this block. +// The TIFF block of a JPEG's EXIF segment, holding every field `Photo`'s typed +// methods return: the camera in the first IFD, the exposure in the Exif IFD, +// and a GPS position of 40°26'46" N, 79°58'56" W, 12.5 m below sea level. +// Offsets count from the start of this block. const TIFF = [ ...[0x4d, 0x4d, 0x00, 0x2a], // big-endian TIFF ...u32(8), // the first IFD's offset @@ -637,9 +637,72 @@ describe("Photo save path, local copy, content and EXIF", () => { await lib.close(); }); - it("reads the common EXIF fields of a JPEG original", async () => { + // Every tag in JPEG_WITH_EXIF, by name, in the order of its IFDs. + const jpegTags = [ + "Make", + "Model", + "Orientation", + "Exif IFD Pointer", + "GPS Info IFD Pointer", + "ExposureTime", + "FNumber", + "ISOSpeedRatings", + "DateTimeOriginal", + "OffsetTimeOriginal", + "FocalLength", + "LensModel", + "GPSLatitudeRef", + "GPSLatitude", + "GPSLongitudeRef", + "GPSLongitude", + "GPSAltitudeRef", + "GPSAltitude", + ]; + // HEIC_WITH_EXIF holds those and the tags exiftool adds to every file. + const heicTags = [ + ...jpegTags, + "YCbCrPositioning", + "ExifVersion", + "ComponentsConfiguration", + "ColorSpace", + "GPSVersionID", + ]; + + it.each([ + [ + "JPEG", + JPEG_WITH_EXIF, + jpegTags, + { + "Exif IFD Pointer": { value: 88 }, + GPSLatitudeRef: { value: ["N"], description: "North latitude" }, + }, + ], + [ + "HEIC", + HEIC_WITH_EXIF, + heicTags, + { + ColorSpace: { value: 0xffff, description: "Uncalibrated" }, + ExifVersion: { description: "0232" }, + }, + ], + ])( + "returns every EXIF tag of a %s original, by name", + async (_, bytes, names, others) => { + const lib = await open({ contentSource: stubSource(bytes) }); + const exif = await lib.photos.byID({ fileID: 1 })!.exif(); + expect(Object.keys(exif).sort()).toEqual([...names].sort()); + // Tags outside the thirteen fields, as exifreader decodes them. + expect(exif).toMatchObject(others); + await lib.close(); + }, + ); + + it("picks the common EXIF fields from a JPEG original's tags", async () => { const lib = await open({ contentSource: stubSource(JPEG_WITH_EXIF) }); - expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({ + const exif = await lib.photos.byID({ fileID: 1 })!.exif(); + expect(readPhotoExif(exif)).toStrictEqual({ make: "Canon", model: "EOS R5", lensModel: "RF50mm F1.8 STM", @@ -658,8 +721,8 @@ describe("Photo save path, local copy, content and EXIF", () => { await lib.close(); }); - // What exif() returns for HEIC_WITH_EXIF, and for JPEG_WITH_EXIF, which - // holds the same values. + // The fields picked from HEIC_WITH_EXIF's tags, and from JPEG_WITH_EXIF's, + // which hold the same values. const heicFields: PhotoExif = { make: "Canon", model: "EOS R5", @@ -676,11 +739,10 @@ describe("Photo save path, local copy, content and EXIF", () => { gpsAltitude: -12.5, }; - it("reads the same common EXIF fields from a HEIC original", async () => { + it("picks the same common EXIF fields from a HEIC original's tags", async () => { const lib = await open({ contentSource: stubSource(HEIC_WITH_EXIF) }); - expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual( - heicFields, - ); + const exif = await lib.photos.byID({ fileID: 1 })!.exif(); + expect(readPhotoExif(exif)).toStrictEqual(heicFields); await lib.close(); }); @@ -694,28 +756,27 @@ describe("Photo save path, local copy, content and EXIF", () => { client: new FilesClient([live]), contentSource: cdnSource(new Map([[1, body]])), }); - expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual( - heicFields, - ); + const exif = await lib.photos.byID({ fileID: 1 })!.exif(); + expect(readPhotoExif(exif)).toStrictEqual(heicFields); await lib.close(); }); // The build's type check, not this test, makes sure `Photo` has a method // for every `PhotoExif` field, whatever the fixtures hold: `Photo` // implements a type with one method per field. This test checks that each - // method gives the same value as exif(). + // method gives the field picked from the tags exif() returns. it.each([ ["JPEG", JPEG_WITH_EXIF], ["HEIC", HEIC_WITH_EXIF], ])( - "has a method for each field exif() returns, giving the same value, for a %s", + "has a method for each field, agreeing with the tags exif() returns, for a %s", async (_, bytes) => { const lib = await open({ contentSource: stubSource(bytes) }); const photo = lib.photos.byID({ fileID: 1 })!; - const exif = await photo.exif(); + const fields = readPhotoExif(await photo.exif()); // The file holds every field, so every method is checked. - expect(exif).toStrictEqual(heicFields); - for (const [field, value] of Object.entries(exif)) { + expect(fields).toStrictEqual(heicFields); + for (const [field, value] of Object.entries(fields)) { expect(await photo[field as keyof PhotoExif]()).toStrictEqual( value, ); @@ -724,7 +785,7 @@ describe("Photo save path, local copy, content and EXIF", () => { }, ); - it("returns no EXIF fields for an original that is not an image", async () => { + it("returns no EXIF tags for an original that is not an image", async () => { const lib = await open(); const photo = lib.photos.byID({ fileID: 1 })!; expect(await photo.exif()).toStrictEqual({}); @@ -732,7 +793,7 @@ describe("Photo save path, local copy, content and EXIF", () => { await lib.close(); }); - it("returns no EXIF fields for a JPEG whose EXIF cannot be parsed", async () => { + it("returns no EXIF tags for a JPEG whose EXIF cannot be parsed", async () => { const lib = await open({ contentSource: stubSource(JPEG_WITH_BAD_EXIF), }); @@ -753,7 +814,7 @@ describe("Photo save path, local copy, content and EXIF", () => { await lib.close(); }); - it("returns no EXIF fields for a video, without fetching it", async () => { + it("returns no EXIF tags for a video, without fetching it", async () => { const video = file(1, 1); video.metadata.fileType = "video"; const source = stubSource(JPEG_WITH_EXIF); From 9e94542a5724618af34c34dfeb28e20500a81c57 Mon Sep 17 00:00:00 2001 From: clawbot <sneak+clawbot@sneak.cloud> Date: Fri, 2 Oct 2026 06:12:56 +0200 Subject: [PATCH 12/39] docker build . stamps the git tag or short commit, not dev (closes #154) A plain `docker build .` now stamps the version from git rather than `dev`/`0.0.0`. After `tsc`, `script/build` writes into `dist/package.json` the version `script/version` decides: `VERSION` when given, otherwise `git describe --tags --always` (the tag; or tag, commits since and short commit; or the short commit), otherwise `package.json`'s. A checkout with `.git` that yields an empty, `dev` or `unknown` version fails the build. `.dockerignore` sends `.git` but not `.git/config`, so no remote URL or credential reaches the image. `ARG VERSION` has no default, and the host scripts' version still wins. Not changed: `REPO_POLICIES.md` still says `ARG VERSION=dev` until the shared policy changes. Model: opus-5-5 Co-authored-by: clawbot <sneak+clawbot@sneak.cloud> --- .dockerignore | 9 +- Dockerfile | 9 +- Makefile | 6 +- README.md | 37 +++++++- TODO.md | 9 ++ script/build | 32 +++++-- script/cibuild | 6 +- script/docker | 6 +- script/version | 41 ++++++++ src/index.ts | 6 +- test/packaging/build-context.test.ts | 15 ++- test/packaging/version.test.ts | 137 +++++++++++++++++++++++++++ 12 files changed, 284 insertions(+), 29 deletions(-) create mode 100755 script/version create mode 100644 test/packaging/version.test.ts diff --git a/.dockerignore b/.dockerignore index e2a5e34..8db1dad 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,12 @@ # Mirrors .gitignore, with one deliberate exception: .gitignore itself stays # in the build context, because prettier 3 reads it as a default ignore file # and dropping it would change what the lint phase's prettier check sees. - -# VCS -.git +# +# .git is deliberately NOT excluded: the build derives the version it stamps +# from it (script/version). It is sent without its config, which holds the +# clone's remote URL and any credential in it, and which the build stage, the +# final image, would otherwise carry. git describe does not need it. +.git/config # OS .DS_Store diff --git a/Dockerfile b/Dockerfile index 144308a..03a95a3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,9 +61,12 @@ RUN script/bootstrap COPY . . -# The version is computed on the host and passed in, because -# .dockerignore excludes .git. -ARG VERSION=dev +# Version stamped into the build: the VERSION build arg when one is given, +# otherwise what script/version derives from the .git the build context +# carries (script/bootstrap installed git), so any `docker build .` of a +# clone stamps its commit. The label can only carry the build arg, and is +# empty without one. +ARG VERSION LABEL org.opencontainers.image.version="${VERSION}" RUN make build diff --git a/Makefile b/Makefile index 80fb8c1..a71e286 100644 --- a/Makefile +++ b/Makefile @@ -26,8 +26,10 @@ check: build: @script/build -build-bin: - nix-shell -p bun --run "bun build bin/quak.ts --compile --outfile bin/quak" +# Bundles the built dist/, so the binary reports the version script/build +# stamped. +build-bin: build + nix-shell -p bun --run "bun build dist/bin/quak.js --compile --outfile bin/quak" install: build-bin mkdir -p ~/bin diff --git a/README.md b/README.md index 97b703e..984fc0f 100644 --- a/README.md +++ b/README.md @@ -126,9 +126,12 @@ alpine. We provide: `script/bootstrap`, then `script/install-precommit` - `script/projectname` — output the project name (our own extension); used by `script/docker` for the image tag -- `script/build` — compile the TypeScript sources into `dist/`, then verify that - the entrypoints `package.json` declares (`main`, `types`, `bin`) are among the - files the compiler wrote, and make the CLI executable (our own extension) +- `script/build` — compile the TypeScript sources into `dist/`, stamp the + version into `dist/package.json`, then verify that the entrypoints + `package.json` declares (`main`, `types`, `bin`) are among the files the + compiler wrote, and make the CLI executable (our own extension) +- `script/version` — print the version `script/build` stamps (our own + extension); see Version below - `script/test` — run the test suite, by building the `test` phase of the `Dockerfile` (vitest, 90s timeout, verbose rerun on failure); requires docker - `script/lint` — run eslint and a prettier check, by building the `lint` phase @@ -176,6 +179,34 @@ an exact version, installed from `yarn.lock` under `--frozen-lockfile` in both places, and reads `.gitignore` as its default ignore file — which is why `.dockerignore` keeps `.gitignore` in the build context. +### Version + +`quak --version` reports the `version` of `dist/package.json`, which +`script/build` writes after compiling; the repo's own `package.json` keeps +`0.0.0`, and that is what the tests, which run from source, report. +`script/version` decides what is written: + +- the `VERSION` environment variable, or the `Dockerfile`'s `VERSION` build arg + (`--build-arg VERSION=...`), when one is given and not empty; +- otherwise, in a checkout with `.git`, `git describe --tags --always`: the tag + on a tagged commit; the tag, the commits since it and the short commit on a + later commit (`v1.2.3-4-gabc1234`); the short commit when no tag is reachable; +- otherwise, as in a source tarball, the version `package.json` declares. + +The build fails if the checkout has `.git` and the version still comes out +empty, `dev` or `unknown`: such a build could not be traced back to its commit. + +`.dockerignore` therefore does not leave out `.git`, so any `docker build .` of +a clone stamps the commit it was built from; a shallow clone stamps a tag only +when the cloned commit itself carries one, and otherwise the short commit. It +leaves out `.git/config`, which holds the clone's remote URL and any credential +in it, so the image carries `.git` without its config; `git describe` does not +need that file. `script/docker` (and so `make docker`) and `script/cibuild` pass +the version they resolve on the host, with `--dirty`, as the build arg, which +takes precedence. The image's `org.opencontainers.image.version` label carries +that build arg only, so a build given none leaves it empty. `make build-bin` +bundles the built `dist/`, so the single binary reports the stamped version too. + ## Rationale Ente is one of very few photo services with a credible end-to-end encryption diff --git a/TODO.md b/TODO.md index d6972af..2907015 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,15 @@ declares one. # Completed Steps +- 2026-10-02: `docker build .` stamps the commit's tag or short commit, not + `dev` (issue 154). `script/build` writes the version `script/version` prints + into `dist/package.json`: the `VERSION` environment variable or build arg when + one is given, otherwise `git describe --tags --always`, otherwise the version + `package.json` declares. `.dockerignore` sends `.git`, and a checkout with + `.git` whose version comes out empty, `dev` or `unknown` fails the build. + `make build-bin` bundles the built `dist/`, so the single binary reports the + same version. + - 2026-10-02: `photo.exif()` returns every EXIF tag in the file as `ExifTags`, keyed by tag name, each as exifreader decodes it, not only the thirteen common fields (issue 156). The embedded thumbnail's tags are under `Thumbnail`, diff --git a/script/build b/script/build index 0a074b7..81df6ba 100755 --- a/script/build +++ b/script/build @@ -1,7 +1,7 @@ #!/bin/sh -# script/build: compile the TypeScript sources into dist/, then verify that -# the artifacts package.json advertises are among the files the compiler -# actually wrote. tsc reports success by exit status alone and knows nothing +# script/build: compile the TypeScript sources into dist/, stamp the version +# script/version prints into it, then verify that the artifacts package.json +# advertises are among the files the compiler actually wrote. tsc reports success by exit status alone and knows nothing # about the manifest, so without this step a green build can still ship a # package whose main, types or bin resolve to nothing. Our own extension to # scripts-to-rule-them-all. @@ -46,13 +46,24 @@ for (const bin of bins) { } # src/index.ts imports ../package.json for the version, which tsc copies to -# dist/package.json. Running the built CLI proves that import resolves from -# dist/ and reports the version package.json declares. +# dist/package.json. The version script/version prints is written into that +# copy only; the repo's own package.json is left as it is. +stamp_version() { + node -e ' +const { readFileSync, writeFileSync } = require("node:fs"); + +const pkg = JSON.parse(readFileSync("dist/package.json", "utf-8")); +pkg.version = process.argv[1]; +writeFileSync("dist/package.json", JSON.stringify(pkg, null, 4) + "\n"); +' "$1" +} + +# Running the built CLI proves the import resolves from dist/ and reports +# the stamped version. verify_version() { built="$(node dist/bin/quak.js --version)" - declared="$(node -p 'require("./package.json").version')" - if [ "$built" != "$declared" ]; then - echo "build: dist/bin/quak.js reports $built, package.json declares $declared" >&2 + if [ "$built" != "$1" ]; then + echo "build: dist/bin/quak.js reports $built, the build stamped $1" >&2 exit 1 fi echo "build: dist/bin/quak.js reports version $built" @@ -60,9 +71,12 @@ verify_version() { main() { cd "$ROOT" + # Own line, so that a failing script/version stops the build. + version="$("$ROOT/script/version")" yarn run tsc + stamp_version "$version" verify_entrypoints - verify_version + verify_version "$version" } main "$@" diff --git a/script/cibuild b/script/cibuild index 38f5706..38fecf3 100755 --- a/script/cibuild +++ b/script/cibuild @@ -15,9 +15,9 @@ main() { cd "$ROOT" # Own line: a failing command substitution inside an argument does # not trip `set -e`, so the inline form degrades silently to an - # empty constant. VERSION is computed here because .dockerignore - # excludes .git, so `git describe` in a build stage yields an empty - # version without failing. + # empty constant. The version resolved here goes in as the VERSION + # build arg, which takes precedence over what the build would derive + # from the .git in its context. version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" docker build --no-cache \ diff --git a/script/docker b/script/docker index c4688e8..940f456 100755 --- a/script/docker +++ b/script/docker @@ -12,9 +12,9 @@ main() { cd "$ROOT" # Own line: a failing command substitution inside an argument does # not trip `set -e`, so the inline form degrades silently to an - # empty constant. VERSION is computed here because .dockerignore - # excludes .git, so `git describe` in a build stage yields an empty - # version without failing. + # empty constant. The version resolved here goes in as the VERSION + # build arg, which takes precedence over what the build would derive + # from the .git in its context. version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" docker build --no-cache \ diff --git a/script/version b/script/version new file mode 100755 index 0000000..2af8a6a --- /dev/null +++ b/script/version @@ -0,0 +1,41 @@ +#!/bin/sh +# script/version: print the version script/build stamps into the built +# package. Our own extension to scripts-to-rule-them-all. +# +# Order of precedence: +# +# 1. $VERSION, if set and not empty: an explicit value, such as the +# Dockerfile's VERSION build arg. +# 2. If this checkout has .git, `git describe --tags --always`: the tag +# on a tagged commit; the tag, the commits since it and the short +# commit on a later commit (v1.2.3-4-gabc1234); the short commit when +# no tag is reachable. +# 3. Otherwise, as in a source tarball, the version package.json declares. +# +# A checkout with .git whose version still comes out empty, dev or unknown +# fails: git is missing or could not read the checkout, and the build could +# not be traced back to its commit. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + version="${VERSION:-}" + if [ -e .git ]; then + if [ -z "$version" ]; then + version="$(git describe --tags --always || true)" + fi + case "$version" in + "" | dev | unknown) + echo "version: $ROOT has .git, but the version came out '$version'" >&2 + exit 1 + ;; + esac + elif [ -z "$version" ]; then + version="$(node -p 'require("./package.json").version')" + fi + echo "$version" +} + +main "$@" diff --git a/src/index.ts b/src/index.ts index 1e8b631..60d90ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ -// package.json is the one place the version is written. tsc copies it to -// dist/package.json, so this path resolves from source and from dist/src/. +// A build reports the version script/build stamps into dist/package.json; +// package.json's own version is reported only when running from source. tsc +// copies package.json to dist/package.json, so this path resolves from source +// and from dist/src/. import pkg from "../package.json" with { type: "json" }; export const VERSION: string = pkg.version; diff --git a/test/packaging/build-context.test.ts b/test/packaging/build-context.test.ts index d6c24e4..ea52581 100644 --- a/test/packaging/build-context.test.ts +++ b/test/packaging/build-context.test.ts @@ -9,8 +9,10 @@ // Excluding too much: Prettier 3 reads `.gitignore` as a default ignore file, // so dropping it from the context silently changes which files the lint // phase's prettier check looks at compared to `make fmt-check` on the host. +// And without `.git`, a `docker build .` given no `VERSION` build arg cannot +// derive the version (`script/version`) and stamps `package.json`'s instead. // -// Neither shows up as a build failure, so they are asserted here. +// None of these shows up as a build failure, so they are asserted here. import { describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -47,6 +49,17 @@ describe(".dockerignore", () => { expect(dockerignore).not.toContain(".gitignore"); }); + it("leaves .git in the build context for the version", () => { + expect(dockerignore).not.toContain(".git"); + expect(dockerignore).not.toContain(".git/"); + }); + + // The build stage is the final image, so a .git/config sent in would + // ship the clone's remote URL and any credential in it. + it("sends .git without its config", () => { + expect(dockerignore).toContain(".git/config"); + }); + // BuildKit lets a `Dockerfile.dockerignore` shadow the root one; such a // file would silently give the build a different, unreviewed context — // and eslint's flat config does not ignore dot-directories, so a stray diff --git a/test/packaging/version.test.ts b/test/packaging/version.test.ts new file mode 100644 index 0000000..c1395fc --- /dev/null +++ b/test/packaging/version.test.ts @@ -0,0 +1,137 @@ +// `script/version` prints the version `script/build` stamps into +// `dist/package.json`, which is what `quak --version` reports from a build. +// A `docker build .` of a clone is given no `VERSION` build arg, so the +// version has to come from the `.git` in its context: the tag on a tagged +// commit; the tag, the commits since it and the short commit on a later commit; +// the short commit when no tag is reachable. A checkout with `.git` that still +// yields no usable version must fail the build, not ship a version nobody can +// trace back to its commit. +// +// Each test copies the script into a fresh directory, which the script then +// treats as the checkout, and executes it there. +import { afterEach, describe, expect, it } from "vitest"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + chmodSync, + copyFileSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); + +let checkout = ""; + +afterEach(() => { + rmSync(checkout, { recursive: true, force: true }); +}); + +// A checkout holding the script and a package.json that declares 0.0.0, with +// no .git yet. +const makeCheckout = (): void => { + checkout = mkdtempSync(join(tmpdir(), "quak-version-")); + mkdirSync(join(checkout, "script")); + copyFileSync( + join(repoRoot, "script/version"), + join(checkout, "script/version"), + ); + chmodSync(join(checkout, "script/version"), 0o755); + writeFileSync(join(checkout, "package.json"), '{ "version": "0.0.0" }\n'); +}; + +// git in the checkout, with an identity and no commit signing, whatever the +// host's own git config says. +const git = (...args: string[]): string => + execFileSync( + "git", + [ + "-c", + "user.name=quak", + "-c", + "user.email=quak@example.invalid", + "-c", + "commit.gpgsign=false", + ...args, + ], + { cwd: checkout, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }, + ).trim(); + +const makeCommittedCheckout = (): void => { + makeCheckout(); + git("init", "-q"); + git("add", "package.json"); + git("commit", "-q", "-m", "first"); +}; + +// Runs the script with nothing in its environment but PATH and, when given, +// VERSION. +const runVersion = (version?: string) => + spawnSync(join(checkout, "script/version"), { + cwd: checkout, + encoding: "utf-8", + env: { PATH: process.env.PATH, VERSION: version }, + }); + +describe("script/version", () => { + it("prints the short commit of an untagged commit", () => { + makeCommittedCheckout(); + expect(runVersion().stdout.trim()).toBe( + git("rev-parse", "--short", "HEAD"), + ); + }); + + it("prints the tag of a tagged commit", () => { + makeCommittedCheckout(); + git("tag", "v1.2.3"); + expect(runVersion().stdout.trim()).toBe("v1.2.3"); + }); + + // script/docker and script/cibuild pass the version they resolve on the + // host as the VERSION build arg. + it("prints the VERSION it is given over what git would derive", () => { + makeCommittedCheckout(); + expect(runVersion("x").stdout.trim()).toBe("x"); + }); + + // `--build-arg VERSION=` must not stamp an empty version. + it("treats an empty VERSION as unset", () => { + makeCommittedCheckout(); + expect(runVersion("").stdout.trim()).toBe( + git("rev-parse", "--short", "HEAD"), + ); + }); + + // A source tarball has no .git: it keeps the version package.json + // declares, and must still build. + it("prints package.json's version where there is no .git", () => { + makeCheckout(); + const result = runVersion(); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("0.0.0"); + }); + + // A repository with no commits stands in for any .git that git cannot + // describe: git missing from the image, or refusing to read the checkout. + it("fails where .git yields no version", () => { + makeCheckout(); + git("init", "-q"); + const result = runVersion(); + expect(result.status).not.toBe(0); + expect(result.stdout).toBe(""); + }); + + it.each(["dev", "unknown"])( + "fails where there is .git and the version is %s", + (version) => { + makeCommittedCheckout(); + const result = runVersion(version); + expect(result.status).not.toBe(0); + expect(result.stdout).toBe(""); + }, + ); +}); From 14ac7d05efe076f1c03e0f52f2679e14fbf494e0 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sat, 3 Oct 2026 15:30:53 +0200 Subject: [PATCH 13/39] Download-albums example test no longer depends on the disk's free space (closes #160) The test left freeBelowBytes at its 50 GiB default, so on a disk with under 50 GiB free the content cache's limit fell to zero. Downloading another photo then evicted photo 1's cached original, and the test fetched it a fourth time instead of copying it. Both of its libraries now open with freeBelowBytes: 0. Every other test that opens a library or content cache with the real free space was read; none has a result that depends on it. The 50 GiB default is unchanged. Model: opus-5-5 --- TODO.md | 6 ++++++ test/examples/download-albums.test.ts | 3 +++ 2 files changed, 9 insertions(+) diff --git a/TODO.md b/TODO.md index 2907015..f4ab28f 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,12 @@ declares one. # Completed Steps +- 2026-10-03: The download-albums example test no longer fails on a disk with + under 50 GiB free (issue 160). It opens its libraries with + `freeBelowBytes: 0`, so the free space of the disk it runs on cannot shrink + the cache, evict the original it cached and add a fetch to the count. No other + test's result depends on that free space. The 50 GiB default is unchanged. + - 2026-10-02: `docker build .` stamps the commit's tag or short commit, not `dev` (issue 154). `script/build` writes the version `script/version` prints into `dist/package.json`: the `VERSION` environment variable or build arg when diff --git a/test/examples/download-albums.test.ts b/test/examples/download-albums.test.ts index ac130a7..f4232cd 100644 --- a/test/examples/download-albums.test.ts +++ b/test/examples/download-albums.test.ts @@ -162,6 +162,9 @@ describe("examples/download-albums.ts", () => { refreshIntervalSeconds: 3600, precacheThumbnails: false, precacheOriginals: false, + // The test counts fetches, so the free space of the disk it + // runs on must not shrink the cache and evict photo 1. + freeBelowBytes: 0, }); const first = await open(); From ab4d5d2cc2aa9d656d0b9bdbb31134a3d1786e0b Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 05:13:27 +0200 Subject: [PATCH 14/39] quak backup writes each file's ML data into its JSON (closes #163) lib.backup() now waits for an ML data fetch, joining the one its refresh started or starting one, before it writes the per-file JSON, and each file's JSON carries the cached payload as mlData. When the fetch fails, each file with no cached ML data gets mlDataError and an entry in failures.json, so the result counts it as failed and quak backup exits 1; the next run fetches again. Judgement call: the wait comes after the originals are downloaded, so the fetch runs alongside the downloads. Judgement call: a failed ML fetch is recorded per file in failures.json, which is how the exit code goes non-zero without changing src/cli-commands.ts. Model: opus-5-5 --- README.md | 26 +++++++---- TODO.md | 7 +++ src/backup.ts | 76 +++++++++++++++++++++++++------- src/library/index.ts | 33 +++++++++----- test/cli/backup.test.ts | 91 +++++++++++++++++++++++++++++++++++++++ test/cli/commands.test.ts | 16 +++++++ 6 files changed, 214 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 984fc0f..3402d5b 100644 --- a/README.md +++ b/README.md @@ -599,8 +599,8 @@ the smallest does not. per unique file, two for a live photo: see below) YYYY-MM-DD.<fileID>.json the file's basic metadata fields quak - keeps, and its private and public magic - metadata + keeps, its private and public magic + metadata, and its ML data YYYY-MM-DD.<fileID>.livephoto.json which of a live photo's two files is which collections/ @@ -619,6 +619,14 @@ name as uploaded, case kept, or `.bin` when it has none or it holds anything but letters and digits. When the date or the time zone changes, the next run saves the original at its new path and leaves the old copy where it is. +A file's JSON holds Ente's ML data for it (its faces and its CLIP embedding) as +`mlData`, the same payload `backup-metadata` writes; a file Ente has no ML data +for has no `mlData`. The backup waits for the library's ML data fetch to finish +before it writes the JSON files. If that fetch fails, each file whose ML data is +not in the cache gets the reason in `mlDataError` instead and counts as failed, +and the next run fetches it again. The JSON files are rewritten on every run, so +ML data that arrived since the last run appears. + `failures.json` records each failed file with the kind of failure, how many times it has been tried and when it was last tried. A file leaves it once it succeeds, or once it is no longer in the library or in the backup's scope. The @@ -655,8 +663,7 @@ subsequent runs, existing originals are skipped. If a download fails, the error is logged and the backup continues with the next file. The exit code is non-zero if any files failed. `quak backup` opens its library with the thumbnail and originals precache off, so the only file content it fetches is the originals the -backup stores. The library's ML data fetch still runs and fills the cache's -`mldata/`. +backup stores. 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 @@ -876,11 +883,12 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange` - `await lib.backup(opts?)` → `BackupResult`. It waits for a refresh as `fresh()` does, puts every in-scope original not already at its save path there as `photo.download()` does (and, with `includeThumbnails`, fetches - thumbnails) through the content cache, and rebuilds the on-disk backup tree - with a durable failure ledger. A fetched original is written straight to its - save path and not into the cache, which then counts it as present; one the - cache already held is copied from there. `BackupOptions`: `downloadDirectory` - (falls back to the library's), `includeOriginals` (default `true`), + thumbnails) through the content cache, waits for an ML data fetch, and + rebuilds the on-disk backup tree, each file's JSON with its ML data, with a + durable failure ledger. A fetched original is written straight to its save + path and not into the cache, which then counts it as present; one the cache + already held is copied from there. `BackupOptions`: `downloadDirectory` (falls + back to the library's), `includeOriginals` (default `true`), `includeThumbnails` (default `false`), `onlyAlbumNames`, and `onProgress`. See Backup layout above for the tree it writes. diff --git a/TODO.md b/TODO.md index f4ab28f..d5084da 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,13 @@ declares one. # Completed Steps +- 2026-10-05: `quak backup` writes each file's ML data (its faces and CLIP + embedding) into the file's JSON as `mlData`, the payload + `lib.mldata.forFile()` returns (issue 163). `lib.backup()` waits for an ML + data fetch before it writes the JSON files. When that fetch fails, each file + whose ML data is not cached gets the reason in `mlDataError` and counts as + failed, and the next run fetches it again. + - 2026-10-03: The download-albums example test no longer fails on a disk with under 50 GiB free (issue 160). It opens its libraries with `freeBelowBytes: 0`, so the free space of the disk it runs on cannot shrink diff --git a/src/backup.ts b/src/backup.ts index 23b92c1..ecc185a 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -3,14 +3,15 @@ // `lib.backup()` waits for a completed refresh of the library (a failed one // fails the backup before any file is touched), then, for every file in scope, // puts its original at its save path under `downloadDirectory`, as -// `Photo.download()` does, and rebuilds the derived views (per-file sidecars, -// per-collection symlink trees, per-collection JSON) from the model. The -// on-disk layout: +// `Photo.download()` does, waits for an ML data fetch, and rebuilds the derived +// views (per-file sidecars, per-collection symlink trees, per-collection JSON) +// from the model. The on-disk layout: // // <downloadDirectory>/ // YYYY/YYYY-MM/YYYY-MM-DD/ // YYYY-MM-DD.<fileID>.<ext> the decrypted bytes (the save path) -// YYYY-MM-DD.<fileID>.json per-file metadata sidecar +// YYYY-MM-DD.<fileID>.json per-file metadata sidecar, with +// the file's ML data // collections/<name>/<title> symlink to the original // collections/<name>.json per-collection metadata // failures.json durable ledger of unresolved failures @@ -29,13 +30,15 @@ // directories of albums that no longer exist. // // Resilience (issue #8): no per-file condition aborts the run. A failed -// download or a failed symlink is caught, recorded in `failures.json` with a -// classification, a running attempt count, and the last-tried time, and the run -// continues. `result.failed` — and thus the CLI's exit code — stays non-zero -// while any failure remains unresolved and clears once every one succeeds. Each -// run reconciles the ledger against the files it attempted, so an entry for a -// file that has since left the library (deleted) or this run's scope is dropped -// rather than counted forever, which would poison a scheduled backup's exit code. +// download, a failed symlink, or ML data missing because the ML data fetch +// failed is caught, recorded in `failures.json` with a classification, a +// running attempt count, and the last-tried time, and the run continues. +// `result.failed` — and thus the CLI's exit code — stays non-zero while any +// failure remains unresolved and clears once every one succeeds. Each run +// reconciles the ledger against the files it attempted, so an entry for a file +// that has since left the library (deleted) or this run's scope is dropped +// rather than counted forever, which would poison a scheduled backup's exit +// code. import { lstatSync, @@ -60,6 +63,7 @@ import { storedAtSavePath, } from "./library/content.js"; import { representative } from "./library/records.js"; +import type { MLData } from "./mldata-fetch.js"; import type { Collection, EnteFile } from "./model/types.js"; export type ProgressCallback = (message: string) => void; @@ -117,6 +121,13 @@ export interface BackupLibrary { destination: string, ): Promise<{ path: string; videoPath?: string }>; thumbnail(fileID: number): Promise<{ path: string }>; + // Wait for an ML data fetch to complete, joining one already running or + // starting one. Rejects with the reason when it fails; resolves at once + // when the library cannot fetch ML data. + fetchMLData(): Promise<void>; + // A file's cached ML data, as `lib.mldata.forFile()` returns it, or + // undefined when none is cached. + mlData(fileID: number): Promise<MLData | undefined>; } type FailureClass = "transient" | "permanent" | "unknown"; @@ -340,7 +351,13 @@ const saveLedger = (path: string, ledger: Map<number, FailureEntry>): void => { ); }; -const writeSidecar = (path: string, file: EnteFile): void => { +// The file's JSON: its basic fields, its magic metadata, and its ML data, or +// the reason the ML data is missing. +const writeSidecar = ( + path: string, + file: EnteFile, + ml: { mlData?: MLData; mlDataError?: string }, +): void => { const meta: Record<string, unknown> = { id: file.id, collectionID: file.collectionID, @@ -349,6 +366,8 @@ const writeSidecar = (path: string, file: EnteFile): void => { }; if (file.magicMetadata) meta.magicMetadata = file.magicMetadata; if (file.pubMagicMetadata) meta.pubMagicMetadata = file.pubMagicMetadata; + if (ml.mlData) meta.mlData = ml.mlData; + if (ml.mlDataError) meta.mlDataError = ml.mlDataError; writeFileSync(path, JSON.stringify(meta, null, 2)); }; @@ -497,12 +516,37 @@ export const runBackup = async ( } // Phase 2: rebuild the derived views from the model. Sidecars first, for - // every present original (this repairs stale ones). + // every present original (this repairs stale ones), each with the file's + // ML data once an ML data fetch has completed. When the fetch fails, a + // file with no cached ML data gets the reason instead and is recorded as + // failed. The next run fetches its ML data again because none is cached. if (includeOriginals) { + let mlDataError: string | undefined; + try { + log("Fetching ML data..."); + await lib.fetchMLData(); + } catch (err) { + mlDataError = errorMessage(err); + log(`FAILED ML data: ${mlDataError}`); + } for (const file of distinct.values()) { - if (storedAtSavePath(downloadDirectory, file) !== undefined) { - const path = savePath(downloadDirectory, file); - writeSidecar(withExtension(path, ".json"), file); + if (storedAtSavePath(downloadDirectory, file) === undefined) { + continue; + } + const path = withExtension( + savePath(downloadDirectory, file), + ".json", + ); + const mlData = await lib.mlData(file.id); + if (mlData === undefined && mlDataError !== undefined) { + writeSidecar(path, file, { mlDataError }); + recordFailure( + file, + collectionName.get(file.collectionID) ?? "", + new Error(`ML data: ${mlDataError}`), + ); + } else { + writeSidecar(path, file, { mlData }); } } } diff --git a/src/library/index.ts b/src/library/index.ts index 8b7a98c..d213345 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -279,7 +279,8 @@ export class Library { private cycle?: Promise<void>; // Guards the ML fetch pass so a slow backfill never runs twice at once; a // refresh whose pass is still running kicks nothing new. Holds the running - // pass, so `close()` can wait for it. + // pass, so `close()` and `backup()` can wait for it. It rejects when the + // pass fails. private mlFetch?: Promise<void>; private closed = false; private lastRefreshAt?: number; @@ -569,8 +570,9 @@ export class Library { // does, joining one already running, and rejects before touching any file // when it fails. Then puts pending originals at their save paths as // `Photo.download()` does (and optional thumbnails) through the content - // cache and pools, and rebuilds the derived symlink/JSON views from the - // model. Throws before any network work when no content cache backs the + // cache and pools, waits for an ML data fetch, and rebuilds the derived + // symlink/JSON views from the model, each file's JSON with its ML data. + // Throws before any network work when no content cache backs the // originals it must fetch. backup(opts?: BackupOptions): Promise<BackupResult> { const downloadDirectory = @@ -593,6 +595,8 @@ export class Library { original: (fileID, destination) => cache!.backupOriginal(fileID, destination), thumbnail: (fileID) => cache!.thumbnail(fileID), + fetchMLData: () => this.fetchMLDataNow(), + mlData: (fileID) => this.mldata.forFile({ fileID }), }, { ...opts, downloadDirectory }, ); @@ -612,7 +616,7 @@ export class Library { this.timer = undefined; } await this.cycle?.catch(() => {}); - await this.mlFetch; + await this.mlFetch?.catch(() => {}); await precacheClosed; } @@ -676,10 +680,9 @@ export class Library { // Backfill ML data for the files this refresh knows about. It runs // outside the refresh's success/failure so a fetch or disk problem // there never marks the metadata refresh failed, and it is not - // awaited so it never stalls the refresh interval. - this.mlFetch ??= this.runMLFetch().finally(() => { - this.mlFetch = undefined; - }); + // awaited so it never stalls the refresh interval. Its failure is + // reported through `status()` and `onProgress`. + void this.fetchMLDataNow().catch(() => {}); } catch (err) { const error = err instanceof Error ? err.message : String(err); this.lastError = error; @@ -784,10 +787,19 @@ export class Library { } } + // Join the running ML fetch pass, or start one when none runs. Resolves at + // once when the client cannot fetch ML data; rejects when the pass fails. + private fetchMLDataNow(): Promise<void> { + this.mlFetch ??= this.runMLFetch().finally(() => { + this.mlFetch = undefined; + }); + return this.mlFetch; + } + // One ML fetch pass: fetch, decrypt and store the ML data for every file // the store knows about that is not cached (or whose `updationTime` has - // advanced), through the metadata pool, and update the CLIP index. Guarded - // so passes never overlap; a failure is reported, not thrown. + // advanced), through the metadata pool, and update the CLIP index. A + // failure is reported through `status()` and `onProgress`, then thrown. private async runMLFetch(): Promise<void> { const mldata = this.mlStore; // Bind so the call keeps the client as its receiver when invoked @@ -831,6 +843,7 @@ export class Library { const error = err instanceof Error ? err.message : String(err); this.lastMLError = error; this.emit({ operation: "fetchMLData", status: "failed", error }); + throw err; } } diff --git a/test/cli/backup.test.ts b/test/cli/backup.test.ts index 57c18b9..085604b 100644 --- a/test/cli/backup.test.ts +++ b/test/cli/backup.test.ts @@ -52,6 +52,7 @@ import { runBackup, type BackupLibrary } from "../../src/backup.js"; import { Library } from "../../src/library/index.js"; import type { ContentSource } from "../../src/library/content.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js"; +import type { MLData } from "../../src/mldata-fetch.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; import { asLivePhoto, @@ -828,6 +829,94 @@ describe("the refresh before a backup", () => { }); }); +// Also serves ML data: file 100 has `ML_PAYLOAD`, the other two have none. +// While `mlError` is set, every ML data request fails with it. +const ML_PAYLOAD = { face: { faces: [] }, clip: { embedding: [0.5, 0.25] } }; + +class MLClient extends MockClient { + mlError?: string; + async fetchMLData(args: { + fileIDs: number[]; + }): Promise<Map<number, MLData>> { + if (this.mlError) throw new Error(this.mlError); + const result = new Map<number, MLData>(); + if (args.fileIDs.includes(100)) result.set(100, ML_PAYLOAD); + return result; + } +} + +// The JSON the backup in `outDir` wrote beside the original of `fileID`. +const fileJSON = (outDir: string, fileID: number): Record<string, unknown> => + JSON.parse(readFileSync(saved(outDir, `${fileID}.json`), "utf-8")); + +describe("ML data in each file's JSON", () => { + it("writes a file's ML data, and no ML field for a file that has none", async () => { + const lib = await openLibrary(stubSource(), new MLClient()); + const outDir = join(root, "backup"); + + const result = await lib.backup({ downloadDirectory: outDir }); + + expect(result.failed).toBe(0); + expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD); + expect(fileJSON(outDir, 101)).not.toHaveProperty("mlData"); + expect(fileJSON(outDir, 101)).not.toHaveProperty("mlDataError"); + await lib.close(); + }); + + it("gives each file with no cached ML data the reason when the fetch fails, and fails the run", async () => { + const client = new MLClient(); + const outDir = join(root, "backup"); + const first = await openLibrary(stubSource(), client); + await first.backup({ downloadDirectory: outDir }); + await first.close(); + + client.mlError = "HTTP 503 from server"; + const lib = await openLibrary(stubSource(), client); + const result = await lib.backup({ downloadDirectory: outDir }); + + // File 100's ML data was cached by the first run and is kept. + expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD); + expect(fileJSON(outDir, 100)).not.toHaveProperty("mlDataError"); + for (const fileID of [101, 200]) { + expect(fileJSON(outDir, fileID).mlDataError).toBe( + "HTTP 503 from server", + ); + } + expect(result.failed).toBe(2); + expect(result.errors.map((e) => [e.fileID, e.error])).toEqual([ + [101, "ML data: HTTP 503 from server"], + [200, "ML data: HTTP 503 from server"], + ]); + expect(Object.keys(readLedger(outDir).files)).toEqual(["101", "200"]); + await lib.close(); + }); + + it("writes the ML data on the run after a failed fetch", async () => { + const client = new MLClient(); + client.mlError = "HTTP 503 from server"; + const outDir = join(root, "backup"); + const first = await openLibrary(stubSource(), client); + expect((await first.backup({ downloadDirectory: outDir })).failed).toBe( + 3, + ); + expect(fileJSON(outDir, 100)).not.toHaveProperty("mlData"); + await first.close(); + + client.mlError = undefined; + const lib = await openLibrary(stubSource(), client); + const result = await lib.backup({ downloadDirectory: outDir }); + + expect(result.failed).toBe(0); + expect(result.skipped).toBe(3); + expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD); + for (const fileID of [100, 101, 200]) { + expect(fileJSON(outDir, fileID)).not.toHaveProperty("mlDataError"); + } + expect(existsSync(join(outDir, "failures.json"))).toBe(false); + await lib.close(); + }); +}); + // Every entry under collections/, one level of directories deep, with each // symlink's target. const tree = (outDir: string): string[] => { @@ -871,6 +960,8 @@ describe("backup album folders", () => { thumbnail: async () => { throw new Error("no thumbnails in this stand-in"); }, + fetchMLData: async () => {}, + mlData: async () => undefined, }); const albumID = (outDir: string, jsonName: string): number => diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index 52d6069..fa24325 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -732,6 +732,22 @@ describe("backup", () => { expect(stderr.text).toBe("Starting backup...\n"); }); + it("exits 1 and lists each file when the ML data fetch fails", async () => { + const client = { + ...fakeClient(), + fetchMLData: async () => { + throw new Error("HTTP 503 from server"); + }, + } as unknown as Client; + expect( + await backupCommand(context(client), join(root, "backup"), {}), + ).toBe(1); + expect(stderr.text).toContain(" Failed: 3\n"); + expect(stderr.text).toContain( + " [Vacation] beach.jpg (id 100): ML data: HTTP 503 from server\n", + ); + }); + it("exits 1 with the error on one line when the refresh fails", async () => { const client = { ...fakeClient(), From 2483c85321185c40aa4b1a8df902fcef42a1b090 Mon Sep 17 00:00:00 2001 From: clawbot <sneak+clawbot@sneak.cloud> Date: Tue, 6 Oct 2026 07:13:24 +0200 Subject: [PATCH 15/39] quak backup writes the account and album records backup-metadata writes (closes #166) quak backup now writes account.json with the account's email and user ID, adds each album's ownerID, isShared, updationTime and, when present, its magicMetadata, pubMagicMetadata and sharedMagicMetadata to the album's JSON, and adds updationTime to each file's JSON. The fields and their order match backup-metadata's account.json, _collection.json and per-file JSON. The interface runBackup drives gains whoami, which the library passes through from its client. Model: opus-5-5 Co-authored-by: clawbot <sneak+clawbot@sneak.cloud> --- README.md | 14 +++++- TODO.md | 6 +++ src/backup.ts | 43 ++++++++++++++--- src/library/index.ts | 4 +- test/cli/backup.test.ts | 102 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 159 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3402d5b..354c53d 100644 --- a/README.md +++ b/README.md @@ -599,8 +599,8 @@ the smallest does not. per unique file, two for a live photo: see below) YYYY-MM-DD.<fileID>.json the file's basic metadata fields quak - keeps, its private and public magic - metadata, and its ML data + keeps, its update time, its private and + public magic metadata, and its ML data YYYY-MM-DD.<fileID>.livephoto.json which of a live photo's two files is which collections/ @@ -608,6 +608,7 @@ the smallest does not. <title> -> ../../YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext> (symlink) <name>.json collection metadata + file list + account.json the account's email and user ID failures.json files that failed and have not yet succeeded ``` @@ -619,6 +620,15 @@ name as uploaded, case kept, or `.bin` when it has none or it holds anything but letters and digits. When the date or the time zone changes, the next run saves the original at its new path and leaves the old copy where it is. +`account.json` holds the account's `email` and `userID`, as `backup-metadata` +writes it. A collection's JSON holds its `id`, `name`, `type`, `ownerID`, +`isShared` and `updationTime`, its `magicMetadata`, `pubMagicMetadata` and +`sharedMagicMetadata` when it has them, as `backup-metadata`'s +`_collection.json` does, and `files`, each file's `id` and `metadata`. A file's +JSON holds its `id`, `collectionID`, `ownerID`, `metadata` and `updationTime`, +and its `magicMetadata` and `pubMagicMetadata` when it has them. Update times +are in microseconds, as Ente records them. + A file's JSON holds Ente's ML data for it (its faces and its CLIP embedding) as `mlData`, the same payload `backup-metadata` writes; a file Ente has no ML data for has no `mlData`. The backup waits for the library's ML data fetch to finish diff --git a/TODO.md b/TODO.md index d5084da..c8baf5c 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,12 @@ declares one. # Completed Steps +- 2026-10-06: `quak backup` writes the account and album records + `backup-metadata` writes (issue 166): `account.json` with the account's + `email` and `userID`, and in each album's JSON its `ownerID`, `isShared`, + `updationTime` and, when present, its three layers of magic metadata. Each + file's JSON gains its `updationTime`. + - 2026-10-05: `quak backup` writes each file's ML data (its faces and CLIP embedding) into the file's JSON as `mlData`, the payload `lib.mldata.forFile()` returns (issue 163). `lib.backup()` waits for an ML diff --git a/src/backup.ts b/src/backup.ts index ecc185a..bfe1dd4 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -14,6 +14,7 @@ // the file's ML data // collections/<name>/<title> symlink to the original // collections/<name>.json per-collection metadata +// account.json the account's email and user ID // failures.json durable ledger of unresolved failures // // A live photo's original is its image and its video, each with its own @@ -64,7 +65,7 @@ import { } from "./library/content.js"; import { representative } from "./library/records.js"; import type { MLData } from "./mldata-fetch.js"; -import type { Collection, EnteFile } from "./model/types.js"; +import type { Collection, EnteFile, FileMetadata } from "./model/types.js"; export type ProgressCallback = (message: string) => void; @@ -108,6 +109,8 @@ export interface BackupResult { // The slice of the library that backup drives. `Library` implements it; a test // can drive backup with a stand-in. export interface BackupLibrary { + // The account the library belongs to. + whoami(): { email: string; userID: number }; refresh(): Promise<void>; listCollections(): Collection[]; listFiles(collectionID: number): EnteFile[]; @@ -363,6 +366,7 @@ const writeSidecar = ( collectionID: file.collectionID, ownerID: file.ownerID, metadata: file.metadata, + updationTime: file.updationTime, }; if (file.magicMetadata) meta.magicMetadata = file.magicMetadata; if (file.pubMagicMetadata) meta.pubMagicMetadata = file.pubMagicMetadata; @@ -371,6 +375,29 @@ const writeSidecar = ( writeFileSync(path, JSON.stringify(meta, null, 2)); }; +// The album's JSON: its basic fields, its magic metadata, and its files. +const writeAlbumJSON = ( + path: string, + c: Collection, + files: { id: number; metadata: FileMetadata }[], +): void => { + const album: Record<string, unknown> = { + id: c.id, + name: c.name, + type: c.type, + ownerID: c.ownerID, + isShared: c.isShared, + updationTime: c.updationTime, + }; + if (c.magicMetadata) album.magicMetadata = c.magicMetadata; + if (c.pubMagicMetadata) album.pubMagicMetadata = c.pubMagicMetadata; + if (c.sharedMagicMetadata) { + album.sharedMagicMetadata = c.sharedMagicMetadata; + } + album.files = files; + writeFileSync(path, JSON.stringify(album, null, 2)); +}; + export const runBackup = async ( lib: BackupLibrary, opts: BackupOptions, @@ -393,6 +420,11 @@ export const runBackup = async ( const collectionsDir = join(downloadDirectory, "collections"); const thumbnailsDir = join(downloadDirectory, "thumbnails"); mkdirSync(collectionsDir, { recursive: true }); + const { email, userID } = lib.whoami(); + writeFileSync( + join(downloadDirectory, "account.json"), + JSON.stringify({ email, userID }, null, 2), + ); if (includeThumbnails) mkdirSync(thumbnailsDir, { recursive: true }); removeLeftoverTempFiles(thumbnailsDir); for (const dir of dateFolders(downloadDirectory)) { @@ -618,13 +650,10 @@ export const runBackup = async ( } } - writeFileSync( + writeAlbumJSON( join(collectionsDir, `${colDirName}.json`), - JSON.stringify( - { id: c.id, name: c.name, type: c.type, files: metaFiles }, - null, - 2, - ), + c, + metaFiles, ); } diff --git a/src/library/index.ts b/src/library/index.ts index d213345..65a9daa 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -571,7 +571,8 @@ export class Library { // when it fails. Then puts pending originals at their save paths as // `Photo.download()` does (and optional thumbnails) through the content // cache and pools, waits for an ML data fetch, and rebuilds the derived - // symlink/JSON views from the model, each file's JSON with its ML data. + // symlink/JSON views from the model, each file's JSON with its ML data, + // beside an `account.json` with the account's email and user ID. // Throws before any network work when no content cache backs the // originals it must fetch. backup(opts?: BackupOptions): Promise<BackupResult> { @@ -589,6 +590,7 @@ export class Library { const cache = this.cache; return runBackup( { + whoami: () => this.client.whoami(), refresh: () => this.refreshNow(), listCollections: () => this.store.listCollections(), listFiles: (id) => this.store.listFiles(id), diff --git a/test/cli/backup.test.ts b/test/cli/backup.test.ts index 085604b..5424242 100644 --- a/test/cli/backup.test.ts +++ b/test/cli/backup.test.ts @@ -12,6 +12,7 @@ * collections/ * <name>/<title> symlink to the original (rebuilt each run) * <name>.json per-collection metadata (rebuilt each run) + * account.json the account's email and user ID * failures.json durable ledger of unresolved failures * * The properties that distinguish backup from a naive download loop, and that @@ -917,6 +918,106 @@ describe("ML data in each file's JSON", () => { }); }); +// The fields `RecordsClient` gives the Vacation album: shared by another +// account, with all three layers of magic metadata. +const SHARED_ALBUM = { + ownerID: 7, + isShared: true, + updationTime: 1700, + magicMetadata: { visibility: 0 }, + pubMagicMetadata: { coverID: 100 }, + sharedMagicMetadata: { visibility: 2 }, +}; + +// Serves the Vacation album with `SHARED_ALBUM`'s fields, and each file with +// the update time 5000 + its ID. +class RecordsClient extends MockClient { + override async collectionsSince(): Promise<CollectionsPage> { + const page = await super.collectionsSince(); + return { + ...page, + collections: page.collections.map((c) => + c.id === 1 ? { ...c, ...SHARED_ALBUM } : c, + ), + }; + } + override async filesSince(args: { + collectionID: number; + }): Promise<FilesPage> { + const page = await super.filesSince(args); + return { + ...page, + files: page.files.map((f) => ({ + ...f, + updationTime: 5000 + f.id, + })), + }; + } +} + +// The JSON the backup in `outDir` wrote for the album named `name`. +const albumJSON = (outDir: string, name: string): Record<string, unknown> => + JSON.parse( + readFileSync(join(outDir, "collections", `${name}.json`), "utf-8"), + ); + +describe("account and album records", () => { + it("writes account.json with the account's email and user ID", async () => { + const lib = await openLibrary(stubSource()); + const outDir = join(root, "backup"); + + await lib.backup({ downloadDirectory: outDir }); + + expect( + JSON.parse(readFileSync(join(outDir, "account.json"), "utf-8")), + ).toEqual({ email: "backup@example.com", userID: USER_ID }); + await lib.close(); + }); + + it("writes each album's owner, sharing, update time and magic metadata into its JSON", async () => { + const lib = await openLibrary(stubSource(), new RecordsClient()); + const outDir = join(root, "backup"); + + await lib.backup({ downloadDirectory: outDir }); + + expect(albumJSON(outDir, "Vacation")).toEqual({ + id: 1, + name: "Vacation", + type: "album", + ...SHARED_ALBUM, + files: [ + { id: 100, metadata: file(100, 1, "beach.jpg").metadata }, + { id: 101, metadata: file(101, 1, "sunset.jpg").metadata }, + ], + }); + // An album with no magic metadata gets no magic metadata fields. + expect(albumJSON(outDir, "Work")).toEqual({ + id: 2, + name: "Work", + type: "album", + ownerID: USER_ID, + isShared: false, + updationTime: 1, + files: [ + { id: 200, metadata: file(200, 2, "diagram.png").metadata }, + ], + }); + await lib.close(); + }); + + it("writes each file's update time into its JSON", async () => { + const lib = await openLibrary(stubSource(), new RecordsClient()); + const outDir = join(root, "backup"); + + await lib.backup({ downloadDirectory: outDir }); + + for (const fileID of [100, 101, 200]) { + expect(fileJSON(outDir, fileID).updationTime).toBe(5000 + fileID); + } + await lib.close(); + }); +}); + // Every entry under collections/, one level of directories deep, with each // symlink's target. const tree = (outDir: string): string[] => { @@ -962,6 +1063,7 @@ describe("backup album folders", () => { }, fetchMLData: async () => {}, mlData: async () => undefined, + whoami: () => ({ email: "backup@example.com", userID: USER_ID }), }); const albumID = (outDir: string, jsonName: string): number => From fb4fff59fed1eabad1535293cf16f0d5cf4ae35f Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 10:30:29 +0200 Subject: [PATCH 16/39] Re-vendor the canonical files from sneak/prompts at dd4027b (closes #171) .dockerignore, .gitignore, .prettierignore and REPO_POLICIES.md are copied from sneak/prompts at dd4027b; .editorconfig, check.yml and .prettierrc already matched. This repository's build artifacts follow the copied content in .gitignore and, anchored, in .dockerignore. The scripts follow the new policy: check runs fmt-check, cibuild bootstraps and runs check before the image build, fmt and fmt-check find the pinned yarn under nvm, and the image's last stage marks /app safe for git. The build-context test expects the new pattern forms. Judgement call: script/precommit runs lint and fmt-check without the tests, so the README's red-phase test commit can still land; the policy allows that form only where local testing is impossible. Not applicable: the issue's .golangci.yml entries; this TypeScript repository has none. Model: opus-5-5 --- .dockerignore | 117 ++++++++++++------- .gitignore | 42 +++++-- .prettierignore | 3 - Dockerfile | 2 + README.md | 57 ++++------ REPO_POLICIES.md | 164 ++++++++++++++++++++------- TODO.md | 8 ++ script/check | 12 +- script/cibuild | 14 +-- script/docker | 5 +- script/fmt | 21 +++- script/fmt-check | 21 +++- script/precommit | 10 +- test/packaging/build-context.test.ts | 25 ++-- 14 files changed, 334 insertions(+), 167 deletions(-) diff --git a/.dockerignore b/.dockerignore index 8db1dad..9902b39 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,53 +1,86 @@ -# Mirrors .gitignore, with one deliberate exception: .gitignore itself stays -# in the build context, because prettier 3 reads it as a default ignore file -# and dropping it would change what the lint phase's prettier check sees. +# .dockerignore does NOT use .gitignore semantics. Docker matches with +# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross +# `/` and an unprefixed pattern is anchored at the context root. Every +# depth-independent pattern therefore needs `**/`, or `config/.env` and +# `certs/server.key` still ship while this file reads as solved. Only +# genuinely root-anchored entries go unprefixed. Never transplant these +# into .gitignore, where `**/` is wrong. # -# .git is deliberately NOT excluded: the build derives the version it stamps -# from it (script/version). It is sent without its config, which holds the -# clone's remote URL and any credential in it, and which the build stage, the -# final image, would otherwise carry. git describe does not need it. -.git/config +# Matching is case-sensitive, so secrets use character ranges rather +# than an ALL-CAPS twin, which would still miss `Server.Key`. +# +# Extend with this repo's own host-built artifacts, written anchored: +# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and +# deletes the package directory from the context. -# OS -.DS_Store -Thumbs.db +# .git is sent without its config. Without a VERSION build argument the +# stage that compiles runs `git describe --tags --always` on .git, which +# does not need .git/config; that file can hold a credential, such as a +# password in a remote URL or the token the CI checkout step stores there. +# Each submodule keeps a config with the same exposure in its git directory +# under .git/modules/, nested again for a submodule's own submodules, or in +# its own .git directory when it keeps one. +# KNOWN GAP: a submodule whose name has a `config` segment (`config`, +# `deploy/config`, `config/lib`) loses its whole git directory, because +# `**/.git/modules/**/config` also matches that segment's directory +# under .git/modules/. Go's version stamping then fails the build; +# nothing leaks. Name such a submodule without that segment: +# `git submodule add --name`. +**/.git/config +**/.git/modules/**/config -# Editors -*.swp -*.swo -*~ -*.bak -.idea/ -.vscode/ -*.sublime-* +# Agent scratch: one full checkout of the repo per in-flight agent. +# Anchored because it occurs once where agents run at the repo root. +# KNOWN GAP: a repo running agents in subdirectories still ships +# `services/api/.claude/` and must add its own anchored entry. +.claude -# Node -node_modules +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Re-include a committed template with a negation if the +# build needs one: `!docs/example.env`. +**/*.[eE][nN][vV] +**/.[eE][nN][vV].* +**/.[eE][nN][vV][rR][cC] -# TypeScript / build artifacts -dist -build -*.tsbuildinfo -coverage -.nyc_output/ +# Private keys and the bundles carrying them. Public certificates +# (*.crt, *.cer) are deliberately absent: they are legitimate inputs. +**/*.[pP][eE][mM] +**/*.[kK][eE][yY] +**/*.[pP]12 +**/*.[pP][fF][xX] +**/[iI][dD]_[rR][sS][aA] +**/[iI][dD]_[dD][sS][aA] +**/[iI][dD]_[eE][cC][dD][sS][aA] +**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK] +**/[iI][dD]_[eE][dD]25519 +**/[iI][dD]_[eE][dD]25519_[sS][kK] -# Vitest -.vitest-cache/ +# Dependencies: restored inside the image, never copied in. +**/node_modules -# Environment / secrets -.env -.env.* -*.pem -*.key +# OS metadata. +**/.DS_Store +**/Thumbs.db + +# Editor state: never a build input, and it churns COPY. +**/*.swp +**/*.swo +**/*~ +**/*.bak +**/.idea +**/.vscode +**/*.sublime-* + +# TypeScript / build artifacts: the image compiles its own. +/dist +/build +/*.tsbuildinfo +/coverage +/.nyc_output +/.vitest-cache # Compiled binary (built by make build-bin); around 100 MB -bin/quak +/bin/quak # quak runtime data (in case anyone runs the CLI from inside the repo) -.quak/ - -# Local per-developer tool state, including agent worktrees. Correctness, -# not context size: a worktree copied in here has its own test/ tree, which -# vitest globs alongside the real one, so the containerised suite runs N+1 -# times over and still reports success. -.claude/ +/.quak diff --git a/.gitignore b/.gitignore index 7820f68..ca2f6e2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,9 +11,41 @@ Thumbs.db .vscode/ *.sublime-* +# Agent scratch (worktrees of this repo, created and destroyed by +# in-flight tooling). Unanchored: .gitignore patterns already match at +# every depth, so no prefix is wanted here. This is not a .dockerignore +# entry and must not be given a `**/` prefix on the way into one. +.claude/ + # Node node_modules/ +# Secrets. Unanchored like every entry above, so each matches at every +# depth. Matching is case-sensitive on Linux, so names use character +# ranges rather than a lowercase form that misses `Server.Key`. + +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Only the templates `example.env` and `sample.env` are +# re-included below. A repository that commits any other template adds +# its own negation after these lines, for example `!.env.example`. +*.[eE][nN][vV] +.[eE][nN][vV].* +.[eE][nN][vV][rR][cC] +!example.env +!sample.env + +# Private keys and the bundles carrying them. +*.[pP][eE][mM] +*.[kK][eE][yY] +*.[pP]12 +*.[pP][fF][xX] +[iI][dD]_[rR][sS][aA] +[iI][dD]_[dD][sS][aA] +[iI][dD]_[eE][cC][dD][sS][aA] +[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK] +[iI][dD]_[eE][dD]25519 +[iI][dD]_[eE][dD]25519_[sS][kK] + # TypeScript / build artifacts dist/ build/ @@ -24,18 +56,8 @@ coverage/ # Vitest .vitest-cache/ -# Environment / secrets -.env -.env.* -*.pem -*.key - # Compiled binary (built by make build-bin) bin/quak # quak runtime data (in case anyone runs the CLI from inside the repo) .quak/ - -# Local per-developer tool settings and scratch state, including the -# worktrees agents check out under this directory -.claude/ diff --git a/.prettierignore b/.prettierignore index ffce635..23d67fc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,2 @@ node_modules/ yarn.lock -dist/ -build/ -coverage/ diff --git a/Dockerfile b/Dockerfile index 03a95a3..f67c63b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,6 +58,8 @@ COPY --from=test /app/package.json /dev/null COPY script/ script/ COPY package.json yarn.lock ./ RUN script/bootstrap +# A tar-stream context keeps the sender's file owners, which git refuses. +RUN git config --system --add safe.directory /app COPY . . diff --git a/README.md b/README.md index 354c53d..dbd05f2 100644 --- a/README.md +++ b/README.md @@ -137,16 +137,15 @@ alpine. We provide: - `script/lint` — run eslint and a prettier check, by building the `lint` phase of the `Dockerfile`; requires docker (see Linting and testing below) - `script/fmt` — format all files with prettier (writes) -- `script/fmt-check` — check formatting on the host (read-only); standalone, and - not called by `script/check` or `script/precommit`, because `script/lint` - already checks formatting in the container -- `script/check` — run all checks: `test`, `lint` (our own extension) +- `script/fmt-check` — check formatting on the host (read-only) +- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own + extension) - `script/docker` — build the image, tagged via `script/projectname` -- `script/cibuild` — build the image (what CI runs); its last stage depends on - the `lint` and `test` phases, so this one build lints, tests and compiles +- `script/cibuild` — what CI runs: `script/bootstrap`, then `script/check`, then + the image build - `script/precommit` — run by the git pre-commit hook (our own extension); runs - `script/lint`, which checks both lint and formatting, but deliberately not the - tests, so the TDD red-phase commit can land + `script/lint` and `script/fmt-check` but deliberately not the tests, so the + TDD red-phase commit can land - `script/install-precommit` — installs the git pre-commit hook (our own extension); `make hooks` shims to it @@ -162,22 +161,19 @@ script runs the tools on the host: docker is required, and that also works where the docker daemon is remote and bind mounts are impossible. The last stage of the `Dockerfile` compiles the package, and it copies a file -from each phase, so it cannot be built unless lint and the tests pass. That is -why `script/cibuild` is a single `docker build`: it runs lint and the tests once -each and then compiles. +from each phase, so it cannot be built unless lint and the tests pass. The image +build in `script/cibuild` therefore runs lint and the tests a second time, after +`script/check` has run them. Every `docker build` in `script/` passes `--no-cache`. On an unchanged tree Docker would otherwise serve the lint and test steps from cache, nothing would run, and the build would still exit 0. -The formatting check is part of the `lint` phase, not a step beside it, so -`script/check` and `script/precommit` do not call `script/fmt-check` as well; -that would run prettier a second time over the same tree for the same verdict. -`script/fmt-check` remains as a standalone entrypoint for asking the formatting -question on the host. Its verdict matches the container's: prettier is pinned to -an exact version, installed from `yarn.lock` under `--frozen-lockfile` in both -places, and reads `.gitignore` as its default ignore file — which is why -`.dockerignore` keeps `.gitignore` in the build context. +`script/fmt-check` runs prettier on the host. Its verdict matches the `lint` +phase's: prettier is pinned to an exact version, installed from `yarn.lock` +under `--frozen-lockfile` in both places, and reads `.gitignore` as its default +ignore file — which is why `.dockerignore` keeps `.gitignore` in the build +context. ### Version @@ -259,11 +255,10 @@ All work on quak is test-driven. No exceptions. history must still show tests landing before (or with) the matching implementation. 8. The pre-commit hook installed by `make hooks` runs `script/precommit`, which - runs `script/lint` — eslint and the prettier check, in the container — but - not the tests, and so not the full `make check`. This is deliberate so the - TDD red-phase commit (failing tests, no implementation yet) can land. The - `test` phase is part of the image build, which is what CI executes via - `script/cibuild`, so a red branch still cannot reach `next`. + runs `script/lint` and `script/fmt-check` but not the tests, and so not the + full `make check`. This is deliberate so the TDD red-phase commit (failing + tests, no implementation yet) can land. CI executes `script/cibuild`, which + runs the tests, so a red branch still cannot reach `next`. ## Design @@ -995,14 +990,12 @@ documents: before the implementation. Tests are the canonical API documentation and must be commented thoroughly. `main` and `next` are always green. -- **Required checks before every commit:** `make lint` must pass — that is - eslint plus the prettier check, and it builds the `lint` phase of the - `Dockerfile`, so it needs docker. The pre-commit hook enforces exactly that. - `make check` (which also runs the tests) must pass before merging into `next`. - `make fmt-check` is available for a host-side formatting check on its own, but - it is not a separate requirement: `make lint` already covers it, and running - both would check formatting twice. Never invoke eslint or prettier directly; - linting runs in the container only. +- **Required checks before every commit:** `make lint` and `make fmt-check` must + pass. `make lint` is eslint plus the prettier check, and it builds the `lint` + phase of the `Dockerfile`, so it needs docker. The pre-commit hook enforces + exactly that. `make check` (which also runs the tests) must pass before + merging into `next`. Never invoke eslint or prettier directly; linting runs in + the container only. - **Formatting:** prettier with 4-space indents and `proseWrap: always` for markdown. Use `make fmt` to format. Use `yarn` not `npm`. diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md index 2256291..20382d1 100644 --- a/REPO_POLICIES.md +++ b/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-09-08 +last_modified: 2026-10-04 --- This document covers repository structure, tooling, and workflow standards. Code @@ -104,10 +104,14 @@ style conventions are in separate documents: `lint` phase and a `test` phase, with the final stage depending on both so the image cannot be built unless they pass. For non-server repos the final stage brings up a development environment; for server repos it is the runtime image. - Dockerfiles install development prerequisites by running `script/bootstrap` - rather than duplicating installs inline; COPY `script/` and the dependency - manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before - running it. + The gate phases and the build stage start from their pinned base images and + install what those images lack either inline, as the canonical Go `Dockerfile` + below does for `git`, or by running `script/bootstrap`, as the `prompts` + repo's own `Dockerfile` does for its yarn packages. The development + environment stage installs development prerequisites by running + `script/bootstrap` rather than duplicating its installs inline. A stage that + runs `script/bootstrap` COPYs `script/` and the dependency manifests + (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it. - **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is no separate lint file. `script/lint` and `script/test` each build one phase @@ -156,11 +160,14 @@ style conventions are in separate documents: not evidence that anything ran: a sub-second build reporting success is a cache hit, not a result. Never invalidate by pruning — `docker builder prune` and friends destroy a build cache shared with every other build on the host. + When a check is added or changed, prove it works by planting a defect it must + catch and watching the run fail on it, then revert the defect. A green run + alone shows neither that the check ran nor that it covers what it should. - **The gate phases are separate stages, and the build stage depends on both.** The lint phase is based on the `golangci/golangci-lint` image (pinned by hash), so lint failures surface in seconds rather than after a full compile, - and the test phase is based on the Go image. The canonical Go repo + and the test phase is based on the Debian Go image. The canonical Go repo `Dockerfile`: ```dockerfile @@ -173,8 +180,9 @@ style conventions are in separate documents: COPY . . RUN golangci-lint run --config .golangci.yml ./... - # Test phase - # golang:1.x-alpine, YYYY-MM-DD + # Test phase. -race needs cgo and so a C compiler, which the Debian Go + # image ships and the alpine one does not. + # golang:1.x, YYYY-MM-DD FROM golang@sha256:... AS test WORKDIR /src COPY go.mod go.sum ./ @@ -191,15 +199,29 @@ style conventions are in separate documents: FROM golang@sha256:... AS builder COPY --from=lint /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null + RUN apk add --no-cache git + # A tar-stream context keeps the sender's file owners, which git refuses. + RUN git config --system --add safe.directory /src WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . - ARG VERSION=dev - RUN CGO_ENABLED=0 go build -trimpath \ - -ldflags="-s -w -X main.Version=${VERSION}" \ - -o /app ./cmd/app/ + # The VERSION build arg when one is given, otherwise + # `git describe --tags --always` on the .git in the build context. With + # .git present, a version that is still empty, dev or unknown fails the + # build: git is missing or could not read the checkout. + ARG VERSION + RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ + if [ -e .git ]; then \ + case "$VERSION" in ""|dev|unknown) \ + echo "version is '$VERSION' although .git is present" >&2; \ + exit 1 ;; \ + esac; \ + fi; \ + CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o /app ./cmd/app/ # Runtime stage, and the last one FROM alpine@sha256:... @@ -221,10 +243,41 @@ style conventions are in separate documents: (e.g. a web frontend compiled in a separate stage), the lint phase must create placeholder files so the embed directives resolve. Example: `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. - - If the project requires CGO or system libraries for linting (e.g. - `vips-dev`), install them in the lint phase with `apk add`. - - `ARG VERSION=dev` is declared in the stage that compiles and supplied by - `script/docker` and `script/cibuild`; no stage may call `git describe`. + - If the project requires CGO or system libraries for linting, install them + in the lint phase. The `golangci/golangci-lint` image is Debian-based and + has no `apk`, so install with `apt-get` under the Debian package name + (`libvips-dev`, where alpine says `vips-dev`), and delete the package + lists in the same `RUN`, so the layer does not keep them: + + ```dockerfile + RUN apt-get update \ + && apt-get install -y --no-install-recommends libvips-dev \ + && rm -rf /var/lib/apt/lists/* + ``` + + - `.dockerignore` lets `.git` into the build context. It keeps out every git + `config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the + repository's own, each submodule's under `.git/modules/`, and that of a + submodule keeping its own `.git` directory. `git describe` does not need + them, and each can hold a credential: a password in a remote URL, or the + token the CI checkout step stores there. A submodule whose name has a + `config` segment (`config`, `deploy/config`, `config/lib`) loses its whole + git directory to `**/.git/modules/**/config`, and Go's version stamping + then fails the build: give it a name without that segment + (`git submodule add --name`). The stage that compiles has `git` (the + Debian Go image has it; an alpine one needs `apk add --no-cache git`) and + takes the version from the `VERSION` build argument when one is given, + otherwise from `git describe --tags --always`. That gives the tag on a + tagged commit; on a later commit, the tag, the number of commits since it + and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no + tag is reachable. The stage that compiles also marks its working directory + safe for git (`git config --system --add safe.directory /src`): a context + sent as a tar stream keeps the sender's file owners, and git refuses a + checkout owned by another user, so the version would come out empty. + `ARG VERSION` has no default, and the build fails if the context carries + `.git` and the version still comes out empty, `dev` or `unknown`. A plain + `docker build .` with no build arguments must succeed; a Dockerfile that + refuses an empty build argument drops that refusal and keeps the argument. - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that runs `script/cibuild` on push, and checks out the repo as its only other step. @@ -233,7 +286,12 @@ style conventions are in separate documents: carry the same guarantee, because its gate phases may come from the cache. The image build is uncached and so runs the gate phases a second time. That is the price of the rule above, and it is worth paying: the image that ships is built - from a run of its own gates rather than from a cache entry. + from a run of its own gates rather than from a cache entry. A separate + workflow limited to `main` by a `branches` list under `on: push` cannot be + checked by review: to try a change to it, add the feature branch to that list + and push, then remove the branch from the list again before merging. Keep any + job in it that publishes behind `if: github.ref_name == 'main'`, so the run + from the feature branch publishes nothing. - Use platform-standard formatters: `black` for Python, `prettier` for JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with @@ -286,17 +344,19 @@ style conventions are in separate documents: ``` `-count=1` is required on both invocations: it defeats Go's test _result_ - cache, so the target cannot report a pass it did not earn, and the rerun - reproduces a failure instead of replaying it. It leaves the build cache - alone, so it costs the runtime of the suite and no recompilation. + cache, so neither run can report a stored pass in place of running the + tests. It leaves the build cache alone, so it costs the runtime of the suite + and no recompilation. - Note that this is a second, independent cache, stacked below the Docker - layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26) - addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes; - it does not guarantee `go test` inside that step does any work, because the - `GOCACHE` baked into earlier image layers survives into the re-executed - step. They are two separate defects requiring two separate fixes, and a fix - for one must not be recorded as covering the other. + That cache is Go's own, separate from Docker's layer cache. Go stores a + passing result in its cache directory (`GOCACHE`), and when the same tests + run again on unchanged code it prints that result, marked `(cached)`, + without running them. That matters on a developer's machine, where this + target runs and the directory lasts from one run to the next. The `test` + phase of the `Dockerfile` needs no `-count=1`: its base image holds no + result for this repo's tests and nothing before its `go test` step runs a + test, so there is nothing to replay. `--no-cache` (above) is what makes that + step run on an unchanged tree. Python example: @@ -340,7 +400,7 @@ style conventions are in separate documents: — which is more dangerous than a short file with no secret patterns at all, because it reads as solved and stops anyone looking. Give every depth-independent pattern the `**/` prefix and leave only genuinely - root-anchored entries unprefixed: `.git`, and the repo's own host-built + root-anchored entries unprefixed: `.claude`, and the repo's own host-built binary, written `/myapp` and never `**/myapp`, which would also match `cmd/myapp/` and delete the package directory from the context. Matching is case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so @@ -365,12 +425,13 @@ style conventions are in separate documents: directory, so a repo running agents in subdirectories still ships `services/api/.claude/` and must add its own anchored entry there. -- **Excluding `.git` means `git describe` cannot run inside any build stage, and - it fails quietly there.** In a build stage there is no repository, so - `git describe` writes nothing to stdout, `-X main.Version=` comes out empty, - the binary reports no version at all, and the build still exits 0. Compute the - version on the host and thread it in as a build arg. `script/docker` and - `script/cibuild` do this, byte-identically across repos: +- **A plain `docker build .` of a clone stamps the version that + `git describe --tags --always` gives**, derived from the `.git` in the build + context as the canonical `Dockerfile` above shows. Without its failure check, + a missing `git` or an unreadable checkout would leave `-X main.Version=` empty + and the build would still exit 0. `script/docker` and `script/cibuild` pass + the version they compute on the host; it takes precedence. They do this + byte-identically across repos: ```sh # Own line: a failing command substitution inside an argument does not @@ -387,7 +448,7 @@ style conventions are in separate documents: fallback is applied — a live check that fires on a build from an export with no `.git` and on a repository with no commits yet. Do not fold it into the substitution as `|| echo unknown`, which makes the guard unreachable. The - Dockerfile's side is `ARG VERSION=dev` in the stage that compiles, declared + Dockerfile's side is `ARG VERSION` in the stage that compiles, declared there because `ARG` is stage-scoped; passing `VERSION` to a repo whose Dockerfile declares no such `ARG` is ignored and costs nothing, which is why the scripts stay byte-identical. One consequence for CI: the standard @@ -426,12 +487,18 @@ style conventions are in separate documents: `test-support` depguard rule, where a repo names its own test-support packages by full import path. A repo adds entries there and changes nothing else, and a re-vendor carries its entries forward. The canonical golangci-lint version is - v2.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base + v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base image - (`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, - which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the - only pin, since no repo installs golangci-lint on the host: bumping the - version means changing it and nothing else. + (`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`, + which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go` + directive must not name a newer Go minor version than the one golangci-lint + was built with, or golangci-lint refuses to lint it: this release lints + `go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo + installs golangci-lint on the host. A repo sets the lint phase digest to the + one named here and re-vendors `.golangci.yml` in the same commit, whichever of + the two prompted the change: the canonical copy can name linters that an older + golangci-lint rejects, and a newer golangci-lint can add linters that + `default: all` switches on until the canonical copy disables them. - **`script/bootstrap` installs a pinned tool by comparing versions, never by testing presence.** An `if ! command -v <tool>; then install; fi` guard tests @@ -455,6 +522,11 @@ style conventions are in separate documents: Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. + A Go tool a repo needs on the host is installed with `go install` pinned to + a commit hash (`go install <package>@<commit hash>`). It is never tracked as + a `go.mod` tool dependency or through a `tools.go` file, either of which + pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`. + - When pinning images or packages by hash, add a comment above the reference with the version and date (YYYY-MM-DD). @@ -567,10 +639,10 @@ style conventions are in separate documents: settings. - Avoid putting files in the repo root unless necessary. Root should contain - only project-level config files (`README.md`, `Makefile`, `Dockerfile`, - `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and - language-specific config). Everything else goes in a subdirectory. Canonical - subdirectory names: + only project-level config files (`README.md`, `AGENTS.md`, `Makefile`, + `Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, + and language-specific config). Everything else goes in a subdirectory. + Canonical subdirectory names: - `bin/` — executable scripts and tools - `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose body is a single call into `internal/` or `pkg/`, no project logic in @@ -601,3 +673,7 @@ style conventions are in separate documents: - Go: `go.mod`, `go.sum`, `.golangci.yml` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - Python: `pyproject.toml` + +- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It + is never committed under a file or directory named after one agent tool, such + as `CLAUDE.md` or `.claude/`, and never split into separate memory files. diff --git a/TODO.md b/TODO.md index c8baf5c..59e3228 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,14 @@ declares one. # Completed Steps +- 2026-10-06: The files this repository copies from `sneak/prompts` are copied + again from its commit `dd4027b` (issue 171). `.gitignore` and `.dockerignore` + keep out more secret files, and this repository's build artifacts follow the + copied content. `script/check` runs `script/fmt-check` again, `script/cibuild` + runs `script/bootstrap` and `script/check` before the image build, + `script/fmt` and `script/fmt-check` find the pinned yarn under nvm, and the + image's last stage marks `/app` safe for git. + - 2026-10-06: `quak backup` writes the account and album records `backup-metadata` writes (issue 166): `account.json` with the account's `email` and `userID`, and in each album's JSON its `ownerID`, `isShared`, diff --git a/script/check b/script/check index 1607369..92875f7 100755 --- a/script/check +++ b/script/check @@ -1,11 +1,8 @@ #!/bin/sh -# script/check: run all checks (test, lint). Our own extension to -# scripts-to-rule-them-all. Both are Docker phases. Must not modify any -# files. -# -# script/fmt-check is not called here, unlike the template: the lint -# phase already runs `prettier --check .`, so calling it would run -# prettier a second time over the same tree for the same verdict. +# script/check: run all checks (test, lint, fmt-check). Our own +# extension to scripts-to-rule-them-all. test and lint are Docker +# phases; fmt-check is native, because a formatter writes the working +# tree. Must not modify any files. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -13,6 +10,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" main() { "$SCRIPT_DIR/test" "$SCRIPT_DIR/lint" + "$SCRIPT_DIR/fmt-check" } main "$@" diff --git a/script/cibuild b/script/cibuild index 38fecf3..d8d3200 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,8 +1,7 @@ #!/bin/sh -# script/cibuild: run the CI build. The image's last stage depends on the -# lint and test phases, so this one build runs eslint, prettier and the -# suite once each and then compiles. Unlike the template it does not run -# script/check first, which would run lint and the tests a second time. +# script/cibuild: run the CI build. It bootstraps first: a CI runner +# checks out and runs this and nothing else, and script/fmt-check runs +# the formatter on the host, which a pristine checkout cannot do. # --no-cache for the same reason as script/docker: the gate phases the # final stage depends on are RUN steps, and a cached one is a check that # did not run. @@ -13,11 +12,12 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" + "$SCRIPT_DIR/bootstrap" + "$SCRIPT_DIR/check" # Own line: a failing command substitution inside an argument does # not trip `set -e`, so the inline form degrades silently to an - # empty constant. The version resolved here goes in as the VERSION - # build arg, which takes precedence over what the build would derive - # from the .git in its context. + # empty constant. The VERSION build argument takes precedence over + # the version a build stage derives from the .git in the context. version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" docker build --no-cache \ diff --git a/script/docker b/script/docker index 940f456..07b626c 100755 --- a/script/docker +++ b/script/docker @@ -12,9 +12,8 @@ main() { cd "$ROOT" # Own line: a failing command substitution inside an argument does # not trip `set -e`, so the inline form degrades silently to an - # empty constant. The version resolved here goes in as the VERSION - # build arg, which takes precedence over what the build would derive - # from the .git in its context. + # empty constant. The VERSION build argument takes precedence over + # the version a build stage derives from the .git in the context. version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" docker build --no-cache \ diff --git a/script/fmt b/script/fmt index 52825df..fe1b79a 100755 --- a/script/fmt +++ b/script/fmt @@ -4,9 +4,28 @@ set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +# Must match the pin in script/bootstrap. +NODE_VERSION="22.17.0" + +# script/bootstrap installs node and yarn under nvm and leaves neither +# on the PATH of the shell that called it, so resolve the pinned +# toolchain here the way bootstrap's own install step does. nvm is a +# bash script, hence the subshell. +run_yarn() { + if command -v yarn >/dev/null 2>&1; then + exec yarn "$@" + fi + if [ ! -s "$HOME/.nvm/nvm.sh" ]; then + echo "fmt: no yarn; run script/bootstrap first" >&2 + exit 1 + fi + exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null && + shift && exec yarn "$@"' bash "$NODE_VERSION" "$@" +} + main() { cd "$ROOT" - yarn run prettier --write . + run_yarn run prettier --write . } main "$@" diff --git a/script/fmt-check b/script/fmt-check index adfaff4..0e164bd 100755 --- a/script/fmt-check +++ b/script/fmt-check @@ -4,9 +4,28 @@ set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +# Must match the pin in script/bootstrap. +NODE_VERSION="22.17.0" + +# script/bootstrap installs node and yarn under nvm and leaves neither +# on the PATH of the shell that called it, so resolve the pinned +# toolchain here the way bootstrap's own install step does. nvm is a +# bash script, hence the subshell. +run_yarn() { + if command -v yarn >/dev/null 2>&1; then + exec yarn "$@" + fi + if [ ! -s "$HOME/.nvm/nvm.sh" ]; then + echo "fmt-check: no yarn; run script/bootstrap first" >&2 + exit 1 + fi + exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null && + shift && exec yarn "$@"' bash "$NODE_VERSION" "$@" +} + main() { cd "$ROOT" - yarn run prettier --check . + run_yarn run prettier --check . } main "$@" diff --git a/script/precommit b/script/precommit index e87c5c8..af98730 100755 --- a/script/precommit +++ b/script/precommit @@ -2,17 +2,17 @@ # script/precommit: run by the git pre-commit hook; fails the commit if # checks fail. Our own extension to scripts-to-rule-them-all. # -# Runs lint but deliberately NOT the tests, so the TDD red-phase commit -# (failing tests, no implementation yet) can land. CI runs -# script/cibuild, whose image build includes the test phase, and so -# catches any branch that ships red. The lint phase includes the -# prettier check, so a badly formatted tree still fails the commit. +# Runs lint and fmt-check but deliberately NOT the tests, so the TDD +# red-phase commit (failing tests, no implementation yet) can land. CI +# runs script/cibuild, which runs the tests, and so catches any branch +# that ships red. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" main() { "$SCRIPT_DIR/lint" + "$SCRIPT_DIR/fmt-check" } main "$@" diff --git a/test/packaging/build-context.test.ts b/test/packaging/build-context.test.ts index ea52581..8d9c103 100644 --- a/test/packaging/build-context.test.ts +++ b/test/packaging/build-context.test.ts @@ -29,18 +29,19 @@ const patterns = (name: string): string[] => const dockerignore = patterns(".dockerignore"); describe(".dockerignore", () => { - // Everything here is either generated, enormous, or secret. `.claude/` is - // the correctness one: see the header comment and issue #25. + // Everything here is either generated, enormous, or secret. `.claude` is + // the correctness one: see the header comment and issue #25. The leading + // `/` anchors an entry at the root of the context. it.each([ - ".claude/", - ".quak/", - "bin/quak", - "node_modules", - "coverage", - "dist", - ".vitest-cache/", - ".nyc_output/", - "*.tsbuildinfo", + ".claude", + "/.quak", + "/bin/quak", + "**/node_modules", + "/coverage", + "/dist", + "/.vitest-cache", + "/.nyc_output", + "/*.tsbuildinfo", ])("keeps %s out of the build context", (pattern) => { expect(dockerignore).toContain(pattern); }); @@ -57,7 +58,7 @@ describe(".dockerignore", () => { // The build stage is the final image, so a .git/config sent in would // ship the clone's remote URL and any credential in it. it("sends .git without its config", () => { - expect(dockerignore).toContain(".git/config"); + expect(dockerignore).toContain("**/.git/config"); }); // BuildKit lets a `Dockerfile.dockerignore` shadow the root one; such a From ad0e11407f17840b2ee672caf4e48b97e129e1e4 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 12:47:40 +0200 Subject: [PATCH 17/39] quak backup retries failed requests for longer (closes #165) src/retry.ts exports UNATTENDED_RETRY_OPTIONS beside the unchanged default: 10 attempts, a 1 s base delay and a 60 s cap, so a request that keeps failing waits at most 243 s before it gives up. bin/quak.ts loads the backup's session with them, so its refresh, ML data and downloads all use them; every other command keeps the default. What is retried and the backoff formula are unchanged. Model: opus-5-5 --- README.md | 26 ++++++++------ TODO.md | 8 +++++ bin/quak.ts | 15 +++++++- src/index.ts | 1 + src/retry.ts | 10 ++++++ test/cli/bin.test.ts | 71 +++++++++++++++++++++++++++++++++++++ test/client/session.test.ts | 18 ++++++++++ test/retry/retry.test.ts | 31 ++++++++++++++++ 8 files changed, 169 insertions(+), 11 deletions(-) create mode 100644 test/cli/bin.test.ts diff --git a/README.md b/README.md index dbd05f2..44c4812 100644 --- a/README.md +++ b/README.md @@ -415,18 +415,24 @@ Backoff is exponential with full jitter: the delay before retry _n_ is `random() * min(maxDelayMs, baseDelayMs * 2 ** (n - 1))`. The exponential term is the ceiling and the wait is drawn below it, so a client that lost many parallel downloads to one CDN blip does not send them all again at the same -instant. Defaults, configurable through `ApiClientOptions.retry`: +instant. The numbers, configurable through `ApiClientOptions.retry`: -| Option | Default | Meaning | -| ------------- | ------- | ----------------------------------- | -| `attempts` | `4` | total calls, not retries | -| `baseDelayMs` | `500` | ceiling for the first retry's delay | -| `maxDelayMs` | `10000` | upper bound on that ceiling | +| Option | Default | `quak backup` | Meaning | +| ------------- | ------- | ------------- | ----------------------------------- | +| `attempts` | `4` | `10` | total calls, not retries | +| `baseDelayMs` | `500` | `1000` | ceiling for the first retry's delay | +| `maxDelayMs` | `10000` | `60000` | upper bound on that ceiling | -With those defaults a file that is going to fail gives up after at most three -and a half seconds of waiting. `sleep` and `random` are injectable through the -same option, which is how the test suite exercises the whole policy without -waiting. +With the defaults a file that is going to fail gives up after at most three and +a half seconds of waiting. `quak backup` usually runs from cron with nobody +watching, so every request it makes uses the `quak backup` column instead, +exported as `UNATTENDED_RETRY_OPTIONS`: a request that keeps failing gives up +after at most 243 seconds of waiting, and usually after about half that, since +each wait is drawn at random below its ceiling. Every other command uses the +defaults. A library user gets the same budget by passing +`UNATTENDED_RETRY_OPTIONS` as `ApiClientOptions.retry`. `sleep` and `random` are +injectable through the same option, which is how the test suite exercises the +whole policy without waiting. Two deadlines, renewed for each attempt: diff --git a/TODO.md b/TODO.md index 59e3228..3721724 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,14 @@ declares one. # Completed Steps +- 2026-10-06: `quak backup` retries a failed request for longer than the other + commands do (issue 165). `src/retry.ts` exports `UNATTENDED_RETRY_OPTIONS` + beside the unchanged default: 10 attempts, a 1 s base delay and a 60 s cap, so + a request that keeps failing waits at most 243 s before it gives up. + `bin/quak.ts` loads the backup's session with them, so its refresh, ML data + and downloads all use them. What is retried and the backoff formula are + unchanged. + - 2026-10-06: The files this repository copies from `sneak/prompts` are copied again from its commit `dd4027b` (issue 171). `.gitignore` and `.dockerignore` keep out more secret files, and this repository's build artifacts follow the diff --git a/bin/quak.ts b/bin/quak.ts index 4c02183..f974348 100644 --- a/bin/quak.ts +++ b/bin/quak.ts @@ -23,6 +23,7 @@ import { run as runCommand } from "../src/cli-run.js"; import { loadSession } from "../src/cli-session.js"; import { Client } from "../src/client.js"; import { VERSION } from "../src/index.js"; +import { UNATTENDED_RETRY_OPTIONS } from "../src/retry.js"; const paths = envPaths("quak", { suffix: "" }); @@ -129,8 +130,20 @@ program ) .argument("<dir>", "Output directory") .option("--json", "Print result as JSON instead of human-readable summary") + // A backup usually runs from cron with nobody watching, so every request + // it makes retries for longer than the other commands' requests do. .action((dir: string, opts: { json?: boolean }) => - run(backupCommand(context(), dir, opts)), + run( + backupCommand( + { + ...context(), + loadSession: (path) => + loadSession(path, { retry: UNATTENDED_RETRY_OPTIONS }), + }, + dir, + opts, + ), + ), ); const helper = program diff --git a/src/index.ts b/src/index.ts index 60d90ee..68abb3b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ export { isRetryable, isSafeToReplay, resolveRetryOptions, + UNATTENDED_RETRY_OPTIONS, withRetry, type ResolvedRetryOptions, type RetryOptions, diff --git a/src/retry.ts b/src/retry.ts index 1198a79..ffa68d9 100644 --- a/src/retry.ts +++ b/src/retry.ts @@ -37,6 +37,16 @@ export const DEFAULT_RETRY_OPTIONS: ResolvedRetryOptions = { random: Math.random, }; +// For a run nobody is watching, such as `quak backup` from cron. A request that +// keeps failing waits at most 243 s in all before it gives up, usually about +// half that, since each wait is drawn at random below its ceiling. +export const UNATTENDED_RETRY_OPTIONS: ResolvedRetryOptions = { + ...DEFAULT_RETRY_OPTIONS, + attempts: 10, + baseDelayMs: 1_000, + maxDelayMs: 60_000, +}; + export const resolveRetryOptions = ( opts?: RetryOptions, ): ResolvedRetryOptions => ({ diff --git a/test/cli/bin.test.ts b/test/cli/bin.test.ts new file mode 100644 index 0000000..bbcbe79 --- /dev/null +++ b/test/cli/bin.test.ts @@ -0,0 +1,71 @@ +/** + * Tests for the retry options `bin/quak.ts` loads the saved session with: + * `quak backup` gets the unattended ones, every other command the default. + * + * 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, + * so the command stops before it makes any request. + */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import type { ApiClientOptions } from "../../src/api/client.js"; +import { UNATTENDED_RETRY_OPTIONS } from "../../src/retry.js"; + +const loaded = vi.hoisted(() => [] as (ApiClientOptions | undefined)[]); + +vi.mock("../../src/cli-session.js", () => ({ + loadSession: (_path: string, apiOptions?: ApiClientOptions) => { + loaded.push(apiOptions); + return null; + }, +})); + +const argv = process.argv; +const dir = mkdtempSync(join(tmpdir(), "quak-bin-test-")); + +// Run `quak <args>` to completion. The "Not logged in" message and the exit +// are swallowed. +const quak = async (...args: string[]): Promise<void> => { + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + process.argv = ["node", "quak", ...args]; + vi.resetModules(); + await import("../../bin/quak.js"); +}; + +afterEach(() => { + process.argv = argv; + loaded.length = 0; + vi.restoreAllMocks(); +}); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("bin/quak.ts session loading", () => { + it("loads the session for backup with the unattended retry options", async () => { + await quak("backup", dir); + // `vi.resetModules()` gave `bin/quak.ts` its own copy of + // `src/retry.ts`, whose `sleep` is a different function, so the + // options are compared by their numbers. + const { attempts, baseDelayMs, maxDelayMs } = UNATTENDED_RETRY_OPTIONS; + expect(loaded).toEqual([ + { + retry: expect.objectContaining({ + attempts, + baseDelayMs, + maxDelayMs, + }), + }, + ]); + }); + + it("loads the session for another command with the default options", async () => { + await quak("collections"); + expect(loaded).toEqual([undefined]); + }); +}); diff --git a/test/client/session.test.ts b/test/client/session.test.ts index 7aa2359..5a14775 100644 --- a/test/client/session.test.ts +++ b/test/client/session.test.ts @@ -12,6 +12,10 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { init, toBase64 } from "../../src/crypto/index.js"; import { Client, type ClientSnapshot } from "../../src/client.js"; import { loadSession } from "../../src/cli-session.js"; +import { + DEFAULT_RETRY_OPTIONS, + UNATTENDED_RETRY_OPTIONS, +} from "../../src/retry.js"; const validSnapshot = (): ClientSnapshot => { const kp = sodium.crypto_box_keypair(); @@ -176,6 +180,20 @@ describe("loadSession", () => { }); }); + it("gives the restored client the retry options it is passed", () => { + const path = join(dir, "retry.json"); + writeFileSync(path, JSON.stringify(validSnapshot())); + const retryOptions = (client: Client | null) => + client!.getApiClient().getRetryOptions(); + + expect( + retryOptions( + loadSession(path, { retry: UNATTENDED_RETRY_OPTIONS }), + ), + ).toEqual(UNATTENDED_RETRY_OPTIONS); + expect(retryOptions(loadSession(path))).toEqual(DEFAULT_RETRY_OPTIONS); + }); + it("says the file is corrupt when it is not JSON", () => { const path = join(dir, "truncated.json"); writeFileSync(path, '{"email": "user@exa'); diff --git a/test/retry/retry.test.ts b/test/retry/retry.test.ts index 394d96e..8f2213f 100644 --- a/test/retry/retry.test.ts +++ b/test/retry/retry.test.ts @@ -43,6 +43,7 @@ import { isRetryable, isSafeToReplay, resolveRetryOptions, + UNATTENDED_RETRY_OPTIONS, withRetry, } from "../../src/retry.js"; import { ApiError, TruncatedStreamError } from "../../src/errors.js"; @@ -639,3 +640,33 @@ describe("retry defaults", () => { } }); }); + +describe("unattended retry options", () => { + it("allow ten attempts, a 1 s base delay and a 60 s cap", () => { + // The numbers the README documents for `quak backup`. + expect(UNATTENDED_RETRY_OPTIONS.attempts).toBe(10); + expect(UNATTENDED_RETRY_OPTIONS.baseDelayMs).toBe(1_000); + expect(UNATTENDED_RETRY_OPTIONS.maxDelayMs).toBe(60_000); + }); + + it("give up on a request that keeps failing after at most 243 s of waiting", async () => { + // `random: () => 1` makes every wait its ceiling, the worst case. + const { sleep, delays } = recordingSleep(); + let calls = 0; + await expect( + withRetry( + () => { + calls++; + return Promise.reject(new ApiError("HTTP 503", 503)); + }, + { ...UNATTENDED_RETRY_OPTIONS, sleep, random: () => 1 }, + ), + ).rejects.toThrow("HTTP 503"); + + expect(calls).toBe(10); + expect(delays).toEqual([ + 1_000, 2_000, 4_000, 8_000, 16_000, 32_000, 60_000, 60_000, 60_000, + ]); + expect(delays.reduce((sum, ms) => sum + ms, 0)).toBe(243_000); + }); +}); From f6317109bcff45700dd0454ec881ada9a6290795 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 14:01:48 +0200 Subject: [PATCH 18/39] An expired session exits 3 with one line saying to run quak login (closes #164) A 401 from the server that ends a command reaches run in src/cli-run.ts as an ApiError; run prints one line saying to run "quak login" and exits 3. quak backup meets it on its first refresh, before it touches any file. For the commands that load the saved session, a missing or corrupt session file keeps its message and also exits 3. Judgement call: quak logout is unchanged; it handles its own errors and deletes the session file whatever the server answers. Judgement call: quak backup still prints its two progress lines before the error line. Model: opus-5-5 --- README.md | 18 ++++++-- TODO.md | 10 +++++ src/cli-commands.ts | 22 +++++----- src/cli-run.ts | 20 ++++++--- test/cli/commands.test.ts | 86 +++++++++++++++++++++++++++++++-------- test/cli/run.test.ts | 23 +++++++++++ 6 files changed, 142 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 44c4812..bf1d9ac 100644 --- a/README.md +++ b/README.md @@ -517,8 +517,17 @@ The CLI stores the snapshot at the platform-appropriate data directory via `0600`. The key material is stored in cleartext in the JSON; treat this file as you would treat the password itself. A missing file is reported as "not logged in"; a file that exists but is corrupt is reported as such, naming the bad -field. Both exit with status 1, except that `quak logout` with no file says -there is no session and exits 0. +field. When the refresh a command starts with gets HTTP 401 from the server, +because it no longer accepts the saved session's token, the command prints one +line, `quak: the saved session is no longer valid; run "quak login"`, with no +stack trace. All three exit with status 3, which means the user must run +`quak login` again. `quak logout` is the exception: with no file it says there +is no session and exits 0, and it handles a corrupt file or a failed server call +as described below. `quak backup` meets an expired session on the refresh that +starts every run, before it touches any file. A session that stops working +partway through a backup instead fails each remaining file into `failures.json`, +so that run exits 1 and the next one stops at its refresh with status 3. No +command but `quak login` ever prompts. `quak logout` ends the session on the server, so the token in `session.json` stops working even in a copy of the file, and then deletes the file. If the @@ -552,8 +561,9 @@ library. The read commands — `collections`, `files`, `get`, `get-thumb`, `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 held. If that round-trip fails, the command prints the error on one line and -exits 1. `--cache-dir` overrides where the cache lives; without it each account -gets its own directory under the per-user cache path. +exits 1, or 3 when the server no longer accepts the saved session (see "Session +handling"). `--cache-dir` overrides where the cache lives; without it each +account gets its own directory under the per-user cache path. `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 diff --git a/TODO.md b/TODO.md index 3721724..3fb0506 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,16 @@ declares one. # Completed Steps +- 2026-10-06: When the server answers HTTP 401 and that ends a command that + loads the saved session, because the server no longer accepts its token, the + command prints one line, + `quak: the saved session is no longer valid; run "quak login"`, and exits 3 + (issue 164). `quak backup` meets that 401 on the refresh it starts with, + before it touches any file. For the same commands, a missing or corrupt + session file keeps its message and also exits 3, so a cron job can tell that + the user must log in again. `quak logout` is unchanged. `run` in + `src/cli-run.ts` recognises the 401, which reaches it unchanged. + - 2026-10-06: `quak backup` retries a failed request for longer than the other commands do (issue 165). `src/retry.ts` exports `UNATTENDED_RETRY_OPTIONS` beside the unchanged default: 10 attempts, a 1 s base delay and a 60 s cap, so diff --git a/src/cli-commands.ts b/src/cli-commands.ts index 3ea8f34..30aa4ee 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -73,7 +73,9 @@ export const saveSession = ( ); }; -// The saved client, or undefined after telling the user why there is none. +// The saved client, or undefined after telling the user why there is none. The +// command then exits 3, the code for "log in again", as `run` in `cli-run.ts` +// does when the server no longer accepts the saved session. const requireSession = (ctx: CliContext): Client | undefined => { let client: Client | null; try { @@ -150,7 +152,7 @@ export const loginCommand = async (ctx: CliContext): Promise<number> => { export const whoamiCommand = async (ctx: CliContext): Promise<number> => { await init(); const client = requireSession(ctx); - if (!client) return 1; + if (!client) return 3; const info = client.whoami(); ctx.stdout.write(JSON.stringify(info) + "\n"); return 0; @@ -201,7 +203,7 @@ export const collectionsCommand = async ( ): Promise<number> => { await init(); const client = requireSession(ctx); - if (!client) return 1; + if (!client) return 3; const lib = await openReadLibrary(ctx, client); try { // Force a server round-trip and list in enumeration order (issue #36 @@ -243,7 +245,7 @@ export const filesCommand = async ( ): Promise<number> => { await init(); const client = requireSession(ctx); - if (!client) return 1; + if (!client) return 3; const collectionID = Number(opts.collection); if (!Number.isFinite(collectionID)) { ctx.stderr.write("Invalid collection ID\n"); @@ -285,7 +287,7 @@ export const getCommand = async ( ): Promise<number> => { await init(); const client = requireSession(ctx); - if (!client) return 1; + if (!client) return 3; const fileID = Number(fileIDStr); if (!Number.isFinite(fileID)) { ctx.stderr.write("Invalid file ID\n"); @@ -343,7 +345,7 @@ export const getThumbCommand = async ( ): Promise<number> => { await init(); const client = requireSession(ctx); - if (!client) return 1; + if (!client) return 3; const fileID = Number(fileIDStr); if (!Number.isFinite(fileID)) { ctx.stderr.write("Invalid file ID\n"); @@ -380,7 +382,7 @@ export const backupMetadataCommand = async ( ): Promise<number> => { await init(); const client = requireSession(ctx); - if (!client) return 1; + if (!client) return 3; const lib = await openReadLibrary(ctx, client); try { // Refresh first so the dump holds current account state, not what the @@ -403,7 +405,7 @@ export const backupCommand = async ( ): Promise<number> => { await init(); const client = requireSession(ctx); - if (!client) return 1; + if (!client) return 3; ctx.stderr.write("Starting backup...\n"); // The precache is off: the backup fetches what it needs, and must not @@ -453,7 +455,7 @@ export const listMissingThumbnailsCommand = async ( ): Promise<number> => { await init(); const client = requireSession(ctx); - if (!client) return 1; + if (!client) return 3; const lib = await openReadLibrary(ctx, client); try { // Refresh first so files added since the cache was written are @@ -491,7 +493,7 @@ export const fixMissingThumbnailsCommand = async ( ): Promise<number> => { await init(); const client = requireSession(ctx); - if (!client) return 1; + if (!client) return 3; const lib = await openReadLibrary(ctx, client); try { // Refresh first so files added since the cache was written are found; diff --git a/src/cli-run.ts b/src/cli-run.ts index c668b49..027bc40 100644 --- a/src/cli-run.ts +++ b/src/cli-run.ts @@ -1,12 +1,15 @@ // Runs one CLI command for `bin/quak.ts` and exits with its code. import type { Writable } from "node:stream"; +import { ApiError } from "./api/client.js"; // Run a command and exit with its code once stdout/stderr have drained. // Exiting before the drain can truncate piped output, and the library can keep // the event loop alive after a command returns, so a plain return could hang. // An error the command throws is printed as one `quak: MESSAGE` line, without -// the stack trace, and exits 1. +// the stack trace, and exits 1. A 401 from the server means it no longer +// accepts the saved session: that prints one line saying to log in again and +// exits 3, as a missing or corrupt session file does. export const run = async ( command: Promise<number>, stdout: Writable, @@ -17,10 +20,17 @@ export const run = async ( try { code = await command; } catch (err) { - stderr.write( - `quak: ${err instanceof Error ? err.message : String(err)}\n`, - ); - code = 1; + if (err instanceof ApiError && err.status === 401) { + stderr.write( + `quak: the saved session is no longer valid; run "quak login"\n`, + ); + code = 3; + } else { + stderr.write( + `quak: ${err instanceof Error ? err.message : String(err)}\n`, + ); + code = 1; + } } const pending = [stdout, stderr].filter((s) => s.writableLength > 0); if (pending.length === 0) { diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index fa24325..633cc35 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -230,20 +230,30 @@ describe("session file", () => { expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(snapshot); }); - it("a missing session exits 1 with 'Not logged in'", async () => { + it("a missing session exits 3 with 'Not logged in' from every command that needs one", async () => { const ctx = { ...context(), loadSession }; - expect(await whoamiCommand(ctx)).toBe(1); - expect(stderr.text).toBe( + const dir = join(root, "backup"); + expect(await whoamiCommand(ctx)).toBe(3); + expect(await collectionsCommand(ctx, {})).toBe(3); + expect(await filesCommand(ctx, { collection: "1" })).toBe(3); + expect(await getCommand(ctx, "100", {})).toBe(3); + expect(await getThumbCommand(ctx, "100", {})).toBe(3); + expect(await backupMetadataCommand(ctx, dir, {})).toBe(3); + expect(await backupCommand(ctx, dir, {})).toBe(3); + expect(await listMissingThumbnailsCommand(ctx, {})).toBe(3); + expect(await fixMissingThumbnailsCommand(ctx, {})).toBe(3); + const notLoggedIn = `Not logged in. Run "quak login" first.\n` + - `Session file: ${join(ctx.sessionDir, "session.json")}\n`, - ); + `Session file: ${join(ctx.sessionDir, "session.json")}\n`; + expect(stderr.text).toBe(notLoggedIn.repeat(9)); expect(stdout.text).toBe(""); + expect(existsSync(dir)).toBe(false); }); - it("a corrupt session exits 1 and says it is corrupt", async () => { + it("a corrupt session exits 3 and says it is corrupt", async () => { const ctx = { ...context(), loadSession }; saveSession(ctx.sessionDir, snapshot); - expect(await collectionsCommand(ctx, {})).toBe(1); + expect(await collectionsCommand(ctx, {})).toBe(3); expect(stderr.text).toContain("is corrupt"); expect(stderr.text).toContain( `Run "quak logout" and then "quak login" to replace it.\n`, @@ -748,15 +758,12 @@ describe("backup", () => { ); }); - it("exits 1 with the error on one line when the refresh fails", async () => { - const client = { - ...fakeClient(), - collectionsSince: async () => { - throw new Error("HTTP 401 from server"); - }, - } as unknown as Client; - const dir = join(root, "backup"); - // Through `run`, as `bin/quak.ts` does, which prints a thrown error. + // Runs `backup` through `run`, as `bin/quak.ts` does, which prints a thrown + // error; returns the exit code and what `run` printed. + const backupThroughRun = async ( + ctx: CliContext, + dir: string, + ): Promise<{ code: number; runText: string }> => { const runStderr = new PassThrough(); let runText = ""; runStderr.on("data", (chunk: Buffer) => { @@ -764,14 +771,57 @@ describe("backup", () => { }); const code = await new Promise<number>((resolve) => { void run( - backupCommand(context(client), dir, {}), + backupCommand(ctx, dir, {}), new PassThrough(), runStderr, resolve, ); }); + return { code, runText }; + }; + + it("exits 1 with the error on one line when the refresh fails", async () => { + const client = { + ...fakeClient(), + collectionsSince: async () => { + throw new Error("HTTP 503 from server"); + }, + } as unknown as Client; + const dir = join(root, "backup"); + const { code, runText } = await backupThroughRun(context(client), dir); expect(code).toBe(1); - expect(runText).toBe("quak: HTTP 401 from server\n"); + expect(runText).toBe("quak: HTTP 503 from server\n"); + expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n"); + expect(existsSync(dir)).toBe(false); + }); + + // A real saved session, read back by `loadSession`, whose server answers + // every request with 401, as it does once it no longer accepts the token. + // The context's prompts throw, so a prompt would end the run with another + // line and exit 1. + it("exits 3 with one line saying to log in again when the server answers 401", async () => { + const key = toBase64(new Uint8Array(32)); + saveSession(join(root, "session"), { + email: "cli@example.com", + userID: USER_ID, + token: "expired", + masterKey: key, + secretKey: key, + publicKey: key, + }); + const unauthorized = async (): Promise<Response> => + new Response(null, { status: 401 }); + const ctx = { + ...context(), + loadSession: (path: string) => + loadSession(path, { fetch: unauthorized }), + }; + const dir = join(root, "backup"); + const { code, runText } = await backupThroughRun(ctx, dir); + expect(code).toBe(3); + expect(runText).toBe( + `quak: the saved session is no longer valid; run "quak login"\n`, + ); expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n"); expect(existsSync(dir)).toBe(false); }); diff --git a/test/cli/run.test.ts b/test/cli/run.test.ts index 29d0a92..cfad348 100644 --- a/test/cli/run.test.ts +++ b/test/cli/run.test.ts @@ -5,6 +5,7 @@ import { PassThrough } from "node:stream"; import { describe, it, expect } from "vitest"; +import { ApiError } from "../../src/api/client.js"; import { run } from "../../src/cli-run.js"; // A stream whose written text is kept in `text`; writes finish at once, so @@ -55,4 +56,26 @@ describe("run", () => { stderr: "quak: offline\n", }); }); + + it("on a 401 from the server says to run quak login, on one line, and exits 3", async () => { + const result = await runToExit( + Promise.reject(new ApiError("unauthorized", 401)), + ); + expect(result).toEqual({ + code: 3, + stdout: "", + stderr: `quak: the saved session is no longer valid; run "quak login"\n`, + }); + }); + + it("prints another HTTP error as it is and exits 1", async () => { + const result = await runToExit( + Promise.reject(new ApiError("forbidden", 403)), + ); + expect(result).toEqual({ + code: 1, + stdout: "", + stderr: "quak: forbidden\n", + }); + }); }); From 31b50a211dc68c8246b98ac9f2315e14bedc4922 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 16:47:31 +0200 Subject: [PATCH 19/39] quak backup writes each original's EXIF, XMP and dimensions into its JSON (closes #167) Each file's JSON gains imageMetadata, what extractImageMetadata finds in the stored original (for a live photo, its image), or the reason the read failed in imageMetadataError; a failed read fails neither the file nor the run. A video is not read. An original is read when the run stores it or when its JSON has neither field; otherwise the field is carried over from that JSON, so a run does not read every original again. The hand-built JPEG fixtures move to test/exif-jpeg.ts so the backup tests can use them. Judgement call: an original with nothing to record gets imageMetadata {} instead of no field, so it is not read again on every run. Model: opus-5-5 --- README.md | 28 +++-- TODO.md | 9 ++ src/backup.ts | 84 +++++++++++-- test/cli/backup.test.ts | 178 +++++++++++++++++++++++++++ test/exif-heic.ts | 4 +- test/exif-jpeg.ts | 89 ++++++++++++++ test/library/content-library.test.ts | 85 +------------ 7 files changed, 371 insertions(+), 106 deletions(-) create mode 100644 test/exif-jpeg.ts diff --git a/README.md b/README.md index bf1d9ac..ac4a043 100644 --- a/README.md +++ b/README.md @@ -611,7 +611,8 @@ the smallest does not. below) YYYY-MM-DD.<fileID>.json the file's basic metadata fields quak keeps, its update time, its private and - public magic metadata, and its ML data + public magic metadata, its ML data, and + its original's EXIF, XMP and dimensions YYYY-MM-DD.<fileID>.livephoto.json which of a live photo's two files is which collections/ @@ -648,6 +649,16 @@ not in the cache gets the reason in `mlDataError` instead and counts as failed, and the next run fetches it again. The JSON files are rewritten on every run, so ML data that arrived since the last run appears. +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` +for a JPEG, `exif` (or `exifRaw` and `exifError`), and `xmp`. An original with +none of these gets `{}`. A video gets no `imageMetadata`: reading a whole video +to look for tags is not worth it. When the original cannot be read, the reason +is in `imageMetadataError` instead; neither the file nor the run fails. The +original is read when the run stores it, or when the JSON beside it has neither +field, as one an earlier version wrote. Otherwise the field is taken from that +JSON when it is rewritten, so a run does not read every stored original again. + `failures.json` records each failed file with the kind of failure, how many times it has been tried and when it was last tried. A file leaves it once it succeeds, or once it is no longer in the library or in the backup's scope. The @@ -905,13 +916,14 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange` `fresh()` does, puts every in-scope original not already at its save path there as `photo.download()` does (and, with `includeThumbnails`, fetches thumbnails) through the content cache, waits for an ML data fetch, and - rebuilds the on-disk backup tree, each file's JSON with its ML data, with a - durable failure ledger. A fetched original is written straight to its save - path and not into the cache, which then counts it as present; one the cache - already held is copied from there. `BackupOptions`: `downloadDirectory` (falls - back to the library's), `includeOriginals` (default `true`), - `includeThumbnails` (default `false`), `onlyAlbumNames`, and `onProgress`. See - Backup layout above for the tree it writes. + rebuilds the on-disk backup tree, each file's JSON with its ML data and its + original's EXIF, XMP and dimensions, with a durable failure ledger. A fetched + original is written straight to its save path and not into the cache, which + then counts it as present; one the cache already held is copied from there. + `BackupOptions`: `downloadDirectory` (falls back to the library's), + `includeOriginals` (default `true`), `includeThumbnails` (default `false`), + `onlyAlbumNames`, and `onProgress`. See Backup layout above for the tree it + writes. ### Request pools diff --git a/TODO.md b/TODO.md index 3fb0506..cbd2391 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,15 @@ declares one. # Completed Steps +- 2026-10-06: `quak backup` writes each original's EXIF, XMP and dimensions into + the file's JSON as `imageMetadata`, what `backup-metadata --exif` records + (issue 167): for a live photo from its image, for a video nothing, and `{}` + for an original with none of them. A failed read puts the reason in + `imageMetadataError` and fails neither the file nor the run. An original is + read when the run stores it or when its JSON has neither field; otherwise the + field is taken from that JSON. The hand-built JPEGs moved to + `test/exif-jpeg.ts`, beside the HEIC. + - 2026-10-06: When the server answers HTTP 401 and that ends a command that loads the saved session, because the server no longer accepts its token, the command prints one line, diff --git a/src/backup.ts b/src/backup.ts index bfe1dd4..4da6aa4 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -11,7 +11,9 @@ // YYYY/YYYY-MM/YYYY-MM-DD/ // YYYY-MM-DD.<fileID>.<ext> the decrypted bytes (the save path) // YYYY-MM-DD.<fileID>.json per-file metadata sidecar, with -// the file's ML data +// the file's ML data and its +// original's EXIF, XMP and +// dimensions // collections/<name>/<title> symlink to the original // collections/<name>.json per-collection metadata // account.json the account's email and user ID @@ -28,7 +30,10 @@ // no unique state, so they are rebuilt every run; that repairs stale sidecars // and missing or broken symlinks left by an earlier crash. A rebuild also // removes the symlinks to originals that no longer belong to an album, and the -// directories of albums that no longer exist. +// directories of albums that no longer exist. The one thing a sidecar takes +// from the sidecar it replaces is its original's EXIF, XMP and dimensions (or +// why they could not be read), so that a run does not read every stored +// original again; a sidecar without them gets them read from the original. // // 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 @@ -53,6 +58,7 @@ import { symlinkSync, writeFileSync, } from "node:fs"; +import { readFile } from "node:fs/promises"; import { dirname, extname, join, relative, resolve } from "node:path"; import { removeLeftoverTempFiles } from "./download/index.js"; @@ -64,6 +70,7 @@ import { storedAtSavePath, } from "./library/content.js"; import { representative } from "./library/records.js"; +import { extractImageMetadata } from "./metadata-backup.js"; import type { MLData } from "./mldata-fetch.js"; import type { Collection, EnteFile, FileMetadata } from "./model/types.js"; @@ -354,12 +361,53 @@ const saveLedger = (path: string, ledger: Map<number, FailureEntry>): void => { ); }; -// The file's JSON: its basic fields, its magic metadata, and its ML data, or -// the reason the ML data is missing. +// A file's EXIF, XMP and dimensions as its JSON holds them: what +// `extractImageMetadata` found in its original, or why the original could not +// be read. +interface ImageMetadata { + imageMetadata?: Record<string, unknown>; + imageMetadataError?: string; +} + +// The image metadata for the file whose original is at `originalPath` (for a +// live photo, its image) and whose JSON is at `jsonPath`. A video gets none, +// as `photo.exif()` reads none. An original stored before this run is not read +// again when its JSON already holds image metadata: that is kept. A failed +// read gives the reason, and fails neither the file nor the run. An original +// with no EXIF, XMP or JPEG dimensions gets `{}`, so it is not read again. +const imageMetadataFor = async ( + file: EnteFile, + originalPath: string, + jsonPath: string, + storedThisRun: boolean, +): Promise<ImageMetadata> => { + if (file.metadata.fileType === "video") return {}; + if (!storedThisRun) { + try { + const { imageMetadata, imageMetadataError } = JSON.parse( + readFileSync(jsonPath, "utf-8"), + ) as ImageMetadata; + if (imageMetadata !== undefined || imageMetadataError !== undefined) + return { imageMetadata, imageMetadataError }; + } catch { + // No JSON yet, or one that cannot be parsed: read the original. + } + } + try { + const bytes = await readFile(originalPath); + return { imageMetadata: extractImageMetadata(bytes) ?? {} }; + } catch (err) { + return { imageMetadataError: errorMessage(err) }; + } +}; + +// The file's JSON: its basic fields, its magic metadata, its ML data or the +// reason the ML data is missing, and its image metadata. const writeSidecar = ( path: string, file: EnteFile, ml: { mlData?: MLData; mlDataError?: string }, + image: ImageMetadata, ): void => { const meta: Record<string, unknown> = { id: file.id, @@ -372,6 +420,10 @@ const writeSidecar = ( if (file.pubMagicMetadata) meta.pubMagicMetadata = file.pubMagicMetadata; if (ml.mlData) meta.mlData = ml.mlData; if (ml.mlDataError) meta.mlDataError = ml.mlDataError; + if (image.imageMetadata) meta.imageMetadata = image.imageMetadata; + if (image.imageMetadataError) { + meta.imageMetadataError = image.imageMetadataError; + } writeFileSync(path, JSON.stringify(meta, null, 2)); }; @@ -468,6 +520,7 @@ export const runBackup = async ( const errors: BackupError[] = []; const failedThisRun = new Set<number>(); + const storedThisRun = new Set<number>(); let downloaded = 0; let skipped = 0; @@ -516,6 +569,7 @@ export const runBackup = async ( await placeOriginal(downloadDirectory, file, (dest) => lib.original(fileID, dest), ); + storedThisRun.add(fileID); downloaded++; } catch (err) { log( @@ -549,9 +603,10 @@ export const runBackup = async ( // Phase 2: rebuild the derived views from the model. Sidecars first, for // every present original (this repairs stale ones), each with the file's - // ML data once an ML data fetch has completed. When the fetch fails, a - // file with no cached ML data gets the reason instead and is recorded as - // failed. The next run fetches its ML data again because none is cached. + // ML data once an ML data fetch has completed, and its image metadata. + // When the fetch fails, a file with no cached ML data gets the reason + // instead and is recorded as failed. The next run fetches its ML data + // again because none is cached. if (includeOriginals) { let mlDataError: string | undefined; try { @@ -562,23 +617,28 @@ export const runBackup = async ( log(`FAILED ML data: ${mlDataError}`); } for (const file of distinct.values()) { - if (storedAtSavePath(downloadDirectory, file) === undefined) { - continue; - } + const stored = storedAtSavePath(downloadDirectory, file); + if (stored === undefined) continue; const path = withExtension( savePath(downloadDirectory, file), ".json", ); + const image = await imageMetadataFor( + file, + stored.path, + path, + storedThisRun.has(file.id), + ); const mlData = await lib.mlData(file.id); if (mlData === undefined && mlDataError !== undefined) { - writeSidecar(path, file, { mlDataError }); + writeSidecar(path, file, { mlDataError }, image); recordFailure( file, collectionName.get(file.collectionID) ?? "", new Error(`ML data: ${mlDataError}`), ); } else { - writeSidecar(path, file, { mlData }); + writeSidecar(path, file, { mlData }, image); } } } diff --git a/test/cli/backup.test.ts b/test/cli/backup.test.ts index 5424242..b30ad5c 100644 --- a/test/cli/backup.test.ts +++ b/test/cli/backup.test.ts @@ -33,6 +33,7 @@ */ import { + chmodSync, existsSync, lstatSync, mkdirSync, @@ -53,12 +54,16 @@ import { runBackup, type BackupLibrary } from "../../src/backup.js"; import { Library } from "../../src/library/index.js"; import type { ContentSource } from "../../src/library/content.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js"; +import { extractImageMetadata } from "../../src/metadata-backup.js"; import type { MLData } from "../../src/mldata-fetch.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; +import { HEIC_WITH_EXIF } from "../exif-heic.js"; +import { JPEG_WITH_EXIF } from "../exif-jpeg.js"; import { asLivePhoto, cdnSource, IMAGE, + livePhotoHash, livePhotoZip, VIDEO, } from "../live-photo.js"; @@ -1018,6 +1023,179 @@ describe("account and album records", () => { }); }); +// What a file's JSON holds as `imageMetadata` for an original of `bytes`. +const imageMetadataOf = (bytes: Uint8Array): unknown => + JSON.parse(JSON.stringify(extractImageMetadata(bytes))); + +describe("image metadata in each file's JSON", () => { + // Serves `files` in the Vacation album and nothing in Work. + class FilesClient extends MockClient { + constructor(private readonly files: EnteFile[]) { + super(); + } + override async filesSince(args: { + collectionID: number; + }): Promise<FilesPage> { + return { + files: args.collectionID === 1 ? this.files : [], + deleted: [], + cursor: 1, + }; + } + } + + // Writes `originals.get(fileID)` as each file's original, looked up at + // each fetch, so a test can change it between runs. + const bytesSource = ( + originals: Map<number, Uint8Array>, + ): ContentSource => ({ + original: async ({ file: f, destination }) => { + const bytes = originals.get(f.id)!; + writeFileSync(destination, bytes); + return { bytesWritten: bytes.length }; + }, + thumbnail: async () => { + throw new Error("no thumbnails in this source"); + }, + }); + + // A JPEG, a HEIC, a video, and a file holding no image metadata. The + // video's bytes are the JPEG's, so reading it would find EXIF. + const clip = file(602, 1, "clip.mov"); + const files = [ + file(600, 1, "photo.jpg"), + file(601, 1, "photo.heic"), + { ...clip, metadata: { ...clip.metadata, fileType: "video" as const } }, + file(603, 1, "notes.png"), + ]; + const originals = (): Map<number, Uint8Array> => + new Map([ + [600, JPEG_WITH_EXIF], + [601, HEIC_WITH_EXIF], + [602, JPEG_WITH_EXIF], + [603, new TextEncoder().encode("not an image")], + ]); + const open = (bytes = originals()): Promise<Library> => + openLibrary(bytesSource(bytes), new FilesClient(files)); + + // Rewrite the JSON of `fileID` without its image metadata, as an earlier + // version of quak wrote it. + const dropImageMetadata = (outDir: string, fileID: number): void => { + const json = fileJSON(outDir, fileID); + delete json.imageMetadata; + writeFileSync(saved(outDir, `${fileID}.json`), JSON.stringify(json)); + }; + + it("writes each new original's image metadata, and none for a video", async () => { + const lib = await open(); + const outDir = join(root, "backup"); + + const result = await lib.backup({ downloadDirectory: outDir }); + + expect(result).toMatchObject({ downloaded: 4, failed: 0 }); + expect(fileJSON(outDir, 600).imageMetadata).toEqual( + imageMetadataOf(JPEG_WITH_EXIF), + ); + expect(fileJSON(outDir, 601).imageMetadata).toEqual( + imageMetadataOf(HEIC_WITH_EXIF), + ); + expect(fileJSON(outDir, 602)).not.toHaveProperty("imageMetadata"); + expect(fileJSON(outDir, 602)).not.toHaveProperty("imageMetadataError"); + // Empty, so that the next run does not read it again. + expect(fileJSON(outDir, 603).imageMetadata).toEqual({}); + await lib.close(); + }); + + it("reads a live photo's image", async () => { + const { file: live, body } = await asLivePhoto( + file(500, 1, "IMG_0500.HEIC"), + livePhotoZip({ "image.heic": HEIC_WITH_EXIF, "video.mov": VIDEO }), + livePhotoHash(HEIC_WITH_EXIF, VIDEO), + ); + const lib = await openLibrary( + cdnSource(new Map([[500, body]])), + new FilesClient([live]), + ); + const outDir = join(root, "backup"); + + await lib.backup({ downloadDirectory: outDir }); + + expect(fileJSON(outDir, 500).imageMetadata).toEqual( + imageMetadataOf(HEIC_WITH_EXIF), + ); + await lib.close(); + }); + + it("keeps the image metadata of an original stored by an earlier run without reading it again", async () => { + const lib = await open(); + const outDir = join(root, "backup"); + await lib.backup({ downloadDirectory: outDir }); + // A read of the original now would give the HEIC's. + writeFileSync(saved(outDir, "600.jpg"), HEIC_WITH_EXIF); + + const second = await lib.backup({ downloadDirectory: outDir }); + + expect(second).toMatchObject({ downloaded: 0, failed: 0 }); + expect(fileJSON(outDir, 600).imageMetadata).toEqual( + imageMetadataOf(JPEG_WITH_EXIF), + ); + await lib.close(); + }); + + it("reads an original stored by an earlier version, whose JSON has no image metadata", async () => { + const lib = await open(); + const outDir = join(root, "backup"); + await lib.backup({ downloadDirectory: outDir }); + dropImageMetadata(outDir, 600); + + const second = await lib.backup({ downloadDirectory: outDir }); + + expect(second).toMatchObject({ downloaded: 0, failed: 0 }); + expect(fileJSON(outDir, 600).imageMetadata).toEqual( + imageMetadataOf(JPEG_WITH_EXIF), + ); + await lib.close(); + }); + + it("reads an original the run stores again, though its JSON has image metadata", async () => { + const bytes = originals(); + const lib = await open(bytes); + const outDir = join(root, "backup"); + await lib.backup({ downloadDirectory: outDir }); + rmSync(saved(outDir, "600.jpg")); + bytes.set(600, HEIC_WITH_EXIF); + + const second = await lib.backup({ downloadDirectory: outDir }); + + expect(second).toMatchObject({ downloaded: 1, failed: 0 }); + expect(fileJSON(outDir, 600).imageMetadata).toEqual( + imageMetadataOf(HEIC_WITH_EXIF), + ); + await lib.close(); + }); + + // Root ignores file permissions, so this fails when run as root. The + // `test` phase of the `Dockerfile` runs as the `node` user. + it("gives the reason an original could not be read, failing neither the file nor the run", async () => { + const lib = await open(); + const outDir = join(root, "backup"); + await lib.backup({ downloadDirectory: outDir }); + dropImageMetadata(outDir, 600); + const original = saved(outDir, "600.jpg"); + chmodSync(original, 0o000); + + const second = await lib + .backup({ downloadDirectory: outDir }) + .finally(() => chmodSync(original, 0o600)); + + expect(second).toMatchObject({ failed: 0, errors: [] }); + expect(existsSync(join(outDir, "failures.json"))).toBe(false); + expect(fileJSON(outDir, 600)).not.toHaveProperty("imageMetadata"); + expect(fileJSON(outDir, 600).imageMetadataError).toMatch(/EACCES/); + await lib.close(); + }); +}); + // Every entry under collections/, one level of directories deep, with each // symlink's target. const tree = (outDir: string): string[] => { diff --git a/test/exif-heic.ts b/test/exif-heic.ts index ad3acc6..30da019 100644 --- a/test/exif-heic.ts +++ b/test/exif-heic.ts @@ -1,7 +1,7 @@ /** * `exif.heic`, beside this file: a real 64x64 HEIC whose EXIF holds the same - * values as the hand-built JPEG in `library/content-library.test.ts`, for the - * tests of `exif()` and `backup-metadata --exif`. + * values as the hand-built JPEG in `exif-jpeg.ts`, for the tests of `exif()`, + * `backup-metadata --exif` and the image metadata `quak backup` records. * * It was made once, in a throwaway node:22-alpine container (Alpine 3.23.3), * with libheif 1.23.0 and exiftool 13.55: diff --git a/test/exif-jpeg.ts b/test/exif-jpeg.ts new file mode 100644 index 0000000..433e94d --- /dev/null +++ b/test/exif-jpeg.ts @@ -0,0 +1,89 @@ +/** + * Hand-built JPEGs for the tests of `exif()` and of the image metadata + * `quak backup` records: one whose EXIF holds the same values as `exif.heic` + * (see `exif-heic.ts`), and one whose EXIF cannot be parsed. + */ + +// Big-endian bytes for the hand-built JPEG below. +const u16 = (n: number): number[] => [n >> 8, n & 0xff]; +const u32 = (n: number): number[] => [...u16(n >>> 16), ...u16(n & 0xffff)]; +const ascii = (s: string): number[] => [...new TextEncoder().encode(s), 0]; +const rational = (num: number, den: number): number[] => [ + ...u32(num), + ...u32(den), +]; +// One IFD entry: tag, type (1 BYTE, 2 ASCII, 3 SHORT, 4 LONG, 5 RATIONAL), +// count, then the value when it fits in 4 bytes, else its offset. +const entry = ( + tag: number, + type: number, + count: number, + value: number[], +): number[] => [...u16(tag), ...u16(type), ...u32(count), ...value]; + +// The TIFF block of a JPEG's EXIF segment, holding every field `Photo`'s typed +// methods return: the camera in the first IFD, the exposure in the Exif IFD, +// and a GPS position of 40°26'46" N, 79°58'56" W, 12.5 m below sea level. +// Offsets count from the start of this block. +const TIFF = [ + ...[0x4d, 0x4d, 0x00, 0x2a], // big-endian TIFF + ...u32(8), // the first IFD's offset + // The first IFD, at 8: five entries, then no next IFD. + ...u16(5), + ...entry(0x010f, 2, 6, u32(74)), // Make + ...entry(0x0110, 2, 7, u32(80)), // Model + ...entry(0x0112, 3, 1, [...u16(6), 0, 0]), // Orientation + ...entry(0x8769, 4, 1, u32(88)), // the Exif IFD's offset + ...entry(0x8825, 4, 1, u32(246)), // the GPS IFD's offset + ...u32(0), + ...ascii("Canon"), // at 74 + ...ascii("EOS R5"), // at 80 + 0, // a pad byte + // The Exif IFD, at 88: seven entries, then no next IFD. + ...u16(7), + ...entry(0x829a, 5, 1, u32(178)), // ExposureTime + ...entry(0x829d, 5, 1, u32(186)), // FNumber + ...entry(0x8827, 3, 1, [...u16(400), 0, 0]), // ISOSpeedRatings + ...entry(0x9003, 2, 20, u32(194)), // DateTimeOriginal + ...entry(0x9011, 2, 7, u32(214)), // OffsetTimeOriginal + ...entry(0x920a, 5, 1, u32(222)), // FocalLength + ...entry(0xa434, 2, 16, u32(230)), // LensModel + ...u32(0), + ...rational(1, 250), // at 178 + ...rational(28, 10), // at 186 + ...ascii("2021:07:15 14:30:00"), // at 194 + ...ascii("+02:00"), // at 214 + 0, // a pad byte + ...rational(50, 1), // at 222 + ...ascii("RF50mm F1.8 STM"), // at 230 + // The GPS IFD, at 246: six entries, then no next IFD. + ...u16(6), + ...entry(0x0001, 2, 2, [...ascii("N"), 0, 0]), // GPSLatitudeRef + ...entry(0x0002, 5, 3, u32(324)), // GPSLatitude + ...entry(0x0003, 2, 2, [...ascii("W"), 0, 0]), // GPSLongitudeRef + ...entry(0x0004, 5, 3, u32(348)), // GPSLongitude + ...entry(0x0005, 1, 1, [1, 0, 0, 0]), // GPSAltitudeRef: below sea level + ...entry(0x0006, 5, 1, u32(372)), // GPSAltitude + ...u32(0), + ...[...rational(40, 1), ...rational(26, 1), ...rational(46, 1)], // at 324 + ...[...rational(79, 1), ...rational(58, 1), ...rational(56, 1)], // at 348 + ...rational(25, 2), // at 372 +]; + +export const JPEG_WITH_EXIF = new Uint8Array([ + ...[0xff, 0xd8], // start of image + ...[0xff, 0xe1, ...u16(2 + 6 + TIFF.length)], // APP1 and its length + ...[...ascii("Exif"), 0], // "Exif\0\0" + ...TIFF, + ...[0xff, 0xda, 0x00, 0x02], // start of scan +]); + +// A JPEG whose EXIF segment is laid out correctly but holds "XX" where the TIFF +// byte order belongs, so exifreader cannot parse it. +export const JPEG_WITH_BAD_EXIF = new Uint8Array([ + ...[0xff, 0xd8], // start of image + ...[0xff, 0xe1, ...u16(2 + 6 + 2)], // APP1 and its length + ...[...ascii("Exif"), 0], // "Exif\0\0" + ...[0x58, 0x58], // "XX" + ...[0xff, 0xda, 0x00, 0x02], // start of scan +]); diff --git a/test/library/content-library.test.ts b/test/library/content-library.test.ts index a8a139f..48a1094 100644 --- a/test/library/content-library.test.ts +++ b/test/library/content-library.test.ts @@ -29,6 +29,7 @@ import type { CollectionsPage, FilesPage } from "../../src/client.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; import { readPhotoExif, type PhotoExif } from "../../src/exif.js"; import { HEIC_WITH_EXIF } from "../exif-heic.js"; +import { JPEG_WITH_BAD_EXIF, JPEG_WITH_EXIF } from "../exif-jpeg.js"; import { asLivePhoto, cdnSource, @@ -256,90 +257,6 @@ describe("Library content wiring", () => { }); }); -// Big-endian bytes for the hand-built JPEG below. -const u16 = (n: number): number[] => [n >> 8, n & 0xff]; -const u32 = (n: number): number[] => [...u16(n >>> 16), ...u16(n & 0xffff)]; -const ascii = (s: string): number[] => [...new TextEncoder().encode(s), 0]; -const rational = (num: number, den: number): number[] => [ - ...u32(num), - ...u32(den), -]; -// One IFD entry: tag, type (1 BYTE, 2 ASCII, 3 SHORT, 4 LONG, 5 RATIONAL), -// count, then the value when it fits in 4 bytes, else its offset. -const entry = ( - tag: number, - type: number, - count: number, - value: number[], -): number[] => [...u16(tag), ...u16(type), ...u32(count), ...value]; - -// The TIFF block of a JPEG's EXIF segment, holding every field `Photo`'s typed -// methods return: the camera in the first IFD, the exposure in the Exif IFD, -// and a GPS position of 40°26'46" N, 79°58'56" W, 12.5 m below sea level. -// Offsets count from the start of this block. -const TIFF = [ - ...[0x4d, 0x4d, 0x00, 0x2a], // big-endian TIFF - ...u32(8), // the first IFD's offset - // The first IFD, at 8: five entries, then no next IFD. - ...u16(5), - ...entry(0x010f, 2, 6, u32(74)), // Make - ...entry(0x0110, 2, 7, u32(80)), // Model - ...entry(0x0112, 3, 1, [...u16(6), 0, 0]), // Orientation - ...entry(0x8769, 4, 1, u32(88)), // the Exif IFD's offset - ...entry(0x8825, 4, 1, u32(246)), // the GPS IFD's offset - ...u32(0), - ...ascii("Canon"), // at 74 - ...ascii("EOS R5"), // at 80 - 0, // a pad byte - // The Exif IFD, at 88: seven entries, then no next IFD. - ...u16(7), - ...entry(0x829a, 5, 1, u32(178)), // ExposureTime - ...entry(0x829d, 5, 1, u32(186)), // FNumber - ...entry(0x8827, 3, 1, [...u16(400), 0, 0]), // ISOSpeedRatings - ...entry(0x9003, 2, 20, u32(194)), // DateTimeOriginal - ...entry(0x9011, 2, 7, u32(214)), // OffsetTimeOriginal - ...entry(0x920a, 5, 1, u32(222)), // FocalLength - ...entry(0xa434, 2, 16, u32(230)), // LensModel - ...u32(0), - ...rational(1, 250), // at 178 - ...rational(28, 10), // at 186 - ...ascii("2021:07:15 14:30:00"), // at 194 - ...ascii("+02:00"), // at 214 - 0, // a pad byte - ...rational(50, 1), // at 222 - ...ascii("RF50mm F1.8 STM"), // at 230 - // The GPS IFD, at 246: six entries, then no next IFD. - ...u16(6), - ...entry(0x0001, 2, 2, [...ascii("N"), 0, 0]), // GPSLatitudeRef - ...entry(0x0002, 5, 3, u32(324)), // GPSLatitude - ...entry(0x0003, 2, 2, [...ascii("W"), 0, 0]), // GPSLongitudeRef - ...entry(0x0004, 5, 3, u32(348)), // GPSLongitude - ...entry(0x0005, 1, 1, [1, 0, 0, 0]), // GPSAltitudeRef: below sea level - ...entry(0x0006, 5, 1, u32(372)), // GPSAltitude - ...u32(0), - ...[...rational(40, 1), ...rational(26, 1), ...rational(46, 1)], // at 324 - ...[...rational(79, 1), ...rational(58, 1), ...rational(56, 1)], // at 348 - ...rational(25, 2), // at 372 -]; - -const JPEG_WITH_EXIF = new Uint8Array([ - ...[0xff, 0xd8], // start of image - ...[0xff, 0xe1, ...u16(2 + 6 + TIFF.length)], // APP1 and its length - ...[...ascii("Exif"), 0], // "Exif\0\0" - ...TIFF, - ...[0xff, 0xda, 0x00, 0x02], // start of scan -]); - -// A JPEG whose EXIF segment is laid out correctly but holds "XX" where the TIFF -// byte order belongs, so exifreader cannot parse it. -const JPEG_WITH_BAD_EXIF = new Uint8Array([ - ...[0xff, 0xd8], // start of image - ...[0xff, 0xe1, ...u16(2 + 6 + 2)], // APP1 and its length - ...[...ascii("Exif"), 0], // "Exif\0\0" - ...[0x58, 0x58], // "XX" - ...[0xff, 0xda, 0x00, 0x02], // start of scan -]); - describe("Photo save path, local copy, content and EXIF", () => { // The same account, with `files` in its album instead. class FilesClient extends MockClient { From bd774229654ce0d882af683b9950d255365dd411 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 20:30:51 +0200 Subject: [PATCH 20/39] quak backup refuses to run while another backup of the same directory runs, exit 2 (closes #169) lib.backup() takes a lock, backup.lock in its download directory, made with proper-lockfile, before its refresh, and removes it when it ends. A second backup of the directory fails at once with an error naming it. quak backup takes the lock itself before it opens its library and passes lockHeld to the backup, so a refused run sends no request; it prints the error as one line and exits 2. A lock untouched for 10 seconds, left by a run that could not remove it, is taken over. Deviation: yarn.lock was regenerated by yarn add in the pinned node image. Judgement call: a run failing at its refresh leaves the directory, empty. Judgement call: a lock removed mid-run stops that run with an uncaught error, the library's default. Model: opus-5-5 --- README.md | 37 ++++++++++++++---- TODO.md | 10 +++++ package.json | 4 +- src/backup.ts | 79 ++++++++++++++++++++++++++++++++------- src/cli-commands.ts | 76 ++++++++++++++++++++++--------------- src/index.ts | 1 + src/library/index.ts | 18 +++++---- test/cli/backup.test.ts | 77 +++++++++++++++++++++++++++++++++++++- test/cli/commands.test.ts | 37 +++++++++++++++++- yarn.lock | 36 ++++++++++++++++++ 10 files changed, 313 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index ac4a043..138caf2 100644 --- a/README.md +++ b/README.md @@ -524,10 +524,10 @@ stack trace. All three exit with status 3, which means the user must run `quak login` again. `quak logout` is the exception: with no file it says there is no session and exits 0, and it handles a corrupt file or a failed server call as described below. `quak backup` meets an expired session on the refresh that -starts every run, before it touches any file. A session that stops working -partway through a backup instead fails each remaining file into `failures.json`, -so that run exits 1 and the next one stops at its refresh with status 3. No -command but `quak login` ever prompts. +starts every run, before it touches any file but its lock (see "Backup layout"). +A session that stops working partway through a backup instead fails each +remaining file into `failures.json`, so that run exits 1 and the next one stops +at its refresh with status 3. No command but `quak login` ever prompts. `quak logout` ends the session on the server, so the token in `session.json` stops working even in a copy of the file, and then deletes the file. If the @@ -621,6 +621,7 @@ the smallest does not. (symlink) <name>.json collection metadata + file list account.json the account's email and user ID + backup.lock the lock a running backup holds (see below) failures.json files that failed and have not yet succeeded ``` @@ -665,6 +666,23 @@ succeeds, or once it is no longer in the library or in the backup's scope. The library's `lib.backup({ includeThumbnails: true })` also writes `thumbnails/<fileID>.jpg` beside `collections/`; `quak backup` does not. +`backup.lock` keeps two backups of the same directory from running at once, such +as a cron run that starts while the previous one is still going. A backup +creates `<dir>` if it is missing and takes the lock before its refresh, and +removes the lock when it ends, whether it succeeds or fails; `quak backup` takes +it before it opens its library, so a refused run sends no request. The lock is a +directory that +[proper-lockfile](https://github.com/moxystudio/node-proper-lockfile) creates +and keeps touching while the backup runs. A second backup of the directory, from +another process or the same one, fails at once: `quak backup` prints +`quak: another backup of <dir> is running` and exits with status 2. A run +stopped with Ctrl-C or `kill` removes the lock as it exits. Only a run that +cannot, such as one killed with SIGKILL or cut off by a crash or power loss, +leaves it behind; once it has gone 10 seconds untouched, the next run takes it +over, so nobody has to remove it. The lock is outside the date folders and +`collections/`, so it is never taken for an original, and the removal of old +album directories never touches it. + A collection's directory and JSON are named after the collection, and a symlink after the file's title, both with unsafe characters replaced. When two collections would get the same name, or two symlinks in one collection the same @@ -912,7 +930,12 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange` `SimilarResult[]` (`{ fileID, score }`, cosine similarity, most similar first, default limit 20). quak bundles no text encoder, so `searchByEmbedding` takes a query vector the caller produced elsewhere. -- `await lib.backup(opts?)` → `BackupResult`. It waits for a refresh as +- `await lib.backup(opts?)` → `BackupResult`. It takes the lock in the download + directory, and fails at once with an error whose `code` is `ELOCKED` while + another backup of it runs. A caller can instead take the lock itself, before + it opens its library, as `quak backup` does: `await lockBackupDirectory(dir)` + takes it, failing the same way, and returns the function that releases it, and + the caller passes `lockHeld: true` to the backup. It waits for a refresh as `fresh()` does, puts every in-scope original not already at its save path there as `photo.download()` does (and, with `includeThumbnails`, fetches thumbnails) through the content cache, waits for an ML data fetch, and @@ -922,8 +945,8 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange` then counts it as present; one the cache already held is copied from there. `BackupOptions`: `downloadDirectory` (falls back to the library's), `includeOriginals` (default `true`), `includeThumbnails` (default `false`), - `onlyAlbumNames`, and `onProgress`. See Backup layout above for the tree it - writes. + `onlyAlbumNames`, `onProgress`, and `lockHeld` (default `false`). See Backup + layout above for the tree it writes. ### Request pools diff --git a/TODO.md b/TODO.md index cbd2391..86de9b9 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,16 @@ declares one. # Completed Steps +- 2026-10-06: Two backups of the same directory never run at once (issue 169). + `lib.backup()` takes a lock, `backup.lock` in its download directory, made + with `proper-lockfile`, before its refresh, and removes it when it ends, + whether it succeeds or fails. A second backup of the directory, from another + process or the same one, fails at once with an error naming the directory. + `quak backup` takes the lock before it opens its library, so a refused run + sends no request; it prints the error as one line and exits 2. A lock that has + gone 10 seconds untouched, left by a run that could not remove it, is taken + over by the next run. + - 2026-10-06: `quak backup` writes each original's EXIF, XMP and dimensions into the file's JSON as `imageMetadata`, what `backup-metadata --exif` records (issue 167): for a live photo from its image, for a video nothing, and `{}` diff --git a/package.json b/package.json index 779a239..63f26ca 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "devDependencies": { "@eslint/js": "9.38.0", "@types/node": "22.18.13", + "@types/proper-lockfile": "4.1.4", "eslint": "9.38.0", "prettier": "3.8.1", "typescript": "5.9.3", @@ -50,6 +51,7 @@ "fast-srp-hap": "2.0.4", "fflate": "0.8.3", "jpeg-js": "0.4.4", - "libsodium-wrappers-sumo": "0.8.4" + "libsodium-wrappers-sumo": "0.8.4", + "proper-lockfile": "4.1.2" } } diff --git a/src/backup.ts b/src/backup.ts index 4da6aa4..bd86910 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -1,11 +1,13 @@ // The backup command, rebuilt on the library API (issue #51). // -// `lib.backup()` waits for a completed refresh of the library (a failed one -// fails the backup before any file is touched), then, for every file in scope, -// puts its original at its save path under `downloadDirectory`, as -// `Photo.download()` does, waits for an ML data fetch, and rebuilds the derived -// views (per-file sidecars, per-collection symlink trees, per-collection JSON) -// from the model. The on-disk layout: +// `lib.backup()` takes the lock in `downloadDirectory` (unless its caller +// already holds it), and fails at once when another backup of it holds the +// lock. It waits for a completed refresh of the library (a failed one fails the +// backup before any file is touched), then, for every file in scope, puts its +// original at its save path under `downloadDirectory`, as `Photo.download()` +// does, waits for an ML data fetch, and rebuilds the derived views (per-file +// sidecars, per-collection symlink trees, per-collection JSON) from the model. +// The on-disk layout: // // <downloadDirectory>/ // YYYY/YYYY-MM/YYYY-MM-DD/ @@ -17,6 +19,7 @@ // collections/<name>/<title> symlink to the original // collections/<name>.json per-collection metadata // account.json the account's email and user ID +// backup.lock the lock, while a backup runs // failures.json durable ledger of unresolved failures // // A live photo's original is its image and its video, each with its own @@ -60,6 +63,7 @@ import { } from "node:fs"; import { readFile } from "node:fs/promises"; import { dirname, extname, join, relative, resolve } from "node:path"; +import lockfile from "proper-lockfile"; import { removeLeftoverTempFiles } from "./download/index.js"; import { sanitizeFileName, withExtension } from "./filename.js"; @@ -89,6 +93,10 @@ export interface BackupOptions { // Restrict the backup to albums with these names; others are left untouched. onlyAlbumNames?: string[]; onProgress?: ProgressCallback; + // The caller already holds the lock in `downloadDirectory`, taken with + // `lockBackupDirectory`, and releases it itself, so the backup does not + // take it. `quak backup` takes it before it opens its library. + lockHeld?: boolean; } export interface BackupError { @@ -450,17 +458,12 @@ const writeAlbumJSON = ( writeFileSync(path, JSON.stringify(album, null, 2)); }; -export const runBackup = async ( +// The backup itself, which `runBackup` below runs while the lock is held. +const runLockedBackup = async ( lib: BackupLibrary, opts: BackupOptions, + downloadDirectory: string, ): Promise<BackupResult> => { - const downloadDirectory = opts.downloadDirectory; - if (!downloadDirectory) { - throw new Error( - "backup requires a downloadDirectory (pass one to backup() or " + - "open the library with one)", - ); - } const includeOriginals = opts.includeOriginals ?? true; const includeThumbnails = opts.includeThumbnails ?? false; const log = opts.onProgress ?? (() => {}); @@ -736,3 +739,51 @@ export const runBackup = async ( errors, }; }; + +// Only one backup of a directory runs at a time, in this process or another. +// Creates `downloadDirectory` if it is missing, takes the lock in it and +// returns the function that releases it; while another backup holds the lock, +// fails at once with an error whose `code` is `ELOCKED`. The lock is the +// directory `backup.lock`, whose modification time proper-lockfile keeps +// current while it is held. One it has not touched for 10 seconds was left by +// a run that could not remove it, and is taken over. +export const lockBackupDirectory = async ( + downloadDirectory: string, +): Promise<() => Promise<void>> => { + mkdirSync(downloadDirectory, { recursive: true }); + try { + return await lockfile.lock(downloadDirectory, { + lockfilePath: join(downloadDirectory, "backup.lock"), + }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err; + throw Object.assign( + new Error(`another backup of ${downloadDirectory} is running`), + { code: "ELOCKED" }, + ); + } +}; + +// Runs the backup holding the lock, which it takes and releases itself unless +// the caller already holds it (`opts.lockHeld`). +export const runBackup = async ( + lib: BackupLibrary, + opts: BackupOptions, +): Promise<BackupResult> => { + const downloadDirectory = opts.downloadDirectory; + if (!downloadDirectory) { + throw new Error( + "backup requires a downloadDirectory (pass one to backup() or " + + "open the library with one)", + ); + } + if (opts.lockHeld) { + return runLockedBackup(lib, opts, downloadDirectory); + } + const release = await lockBackupDirectory(downloadDirectory); + try { + return await runLockedBackup(lib, opts, downloadDirectory); + } finally { + await release(); + } +}; diff --git a/src/cli-commands.ts b/src/cli-commands.ts index 30aa4ee..d876d51 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -20,6 +20,7 @@ import { type ClientSnapshot, type LoginOptions, } from "./client.js"; +import { lockBackupDirectory } from "./backup.js"; import { init } from "./crypto/index.js"; import { defaultCacheDirectory, @@ -408,44 +409,59 @@ export const backupCommand = async ( if (!client) return 3; ctx.stderr.write("Starting backup...\n"); - // The precache is off: the backup fetches what it needs, and must not - // also fill the cache with every thumbnail and the recent originals. - const lib = await Library.open({ - client, - downloadDirectory: dir, - cacheDirectory: ctx.cacheDir, - precacheThumbnails: false, - precacheOriginals: false, - }); + // The lock is taken before the library opens and starts its refresh, so a + // run refused while another backup of `dir` runs sends no request. + let release: () => Promise<void>; try { - const result = await lib.backup({ + release = await lockBackupDirectory(dir); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err; + ctx.stderr.write(`quak: ${(err as Error).message}\n`); + return 2; + } + try { + // The precache is off: the backup fetches what it needs, and must not + // also fill the cache with every thumbnail and the recent originals. + const lib = await Library.open({ + client, downloadDirectory: dir, - onProgress: (msg) => { - if (!opts.json) ctx.stderr.write(msg + "\n"); - }, + cacheDirectory: ctx.cacheDir, + precacheThumbnails: false, + precacheOriginals: false, }); + try { + const result = await lib.backup({ + downloadDirectory: dir, + lockHeld: true, + onProgress: (msg) => { + if (!opts.json) ctx.stderr.write(msg + "\n"); + }, + }); - if (opts.json) { - ctx.stdout.write(JSON.stringify(result, null, 2) + "\n"); - } else { - ctx.stderr.write("\n--- Backup complete ---\n"); - ctx.stderr.write(` Total files: ${result.totalFiles}\n`); - ctx.stderr.write(` Downloaded: ${result.downloaded}\n`); - ctx.stderr.write(` Skipped: ${result.skipped}\n`); - ctx.stderr.write(` Failed: ${result.failed}\n`); - if (result.errors.length > 0) { - ctx.stderr.write("\nFailed files:\n"); - for (const e of result.errors) { - ctx.stderr.write( - ` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`, - ); + if (opts.json) { + ctx.stdout.write(JSON.stringify(result, null, 2) + "\n"); + } else { + ctx.stderr.write("\n--- Backup complete ---\n"); + ctx.stderr.write(` Total files: ${result.totalFiles}\n`); + ctx.stderr.write(` Downloaded: ${result.downloaded}\n`); + ctx.stderr.write(` Skipped: ${result.skipped}\n`); + ctx.stderr.write(` Failed: ${result.failed}\n`); + if (result.errors.length > 0) { + ctx.stderr.write("\nFailed files:\n"); + for (const e of result.errors) { + ctx.stderr.write( + ` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`, + ); + } } } - } - return result.failed > 0 ? 1 : 0; + return result.failed > 0 ? 1 : 0; + } finally { + await lib.close(); + } } finally { - await lib.close(); + await release(); } }; diff --git a/src/index.ts b/src/index.ts index 68abb3b..1328893 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,6 +67,7 @@ export { type EnsureOptions, type EnsureResult, type EnsureEvent, + lockBackupDirectory, runBackup, type BackupOptions, type BackupResult, diff --git a/src/library/index.ts b/src/library/index.ts index 65a9daa..489dd88 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -94,6 +94,7 @@ import type { Collection, EnteFile } from "../model/types.js"; import { runBackup, type BackupOptions, type BackupResult } from "../backup.js"; export { + lockBackupDirectory, runBackup, type BackupOptions, type BackupResult, @@ -566,13 +567,16 @@ export class Library { // Back up every in-scope file to `opts.downloadDirectory`, or else the // library's, each original at its save path, with a durable failure - // ledger (issue #51). Waits for a completed refresh first, as `fresh()` - // does, joining one already running, and rejects before touching any file - // when it fails. Then puts pending originals at their save paths as - // `Photo.download()` does (and optional thumbnails) through the content - // cache and pools, waits for an ML data fetch, and rebuilds the derived - // symlink/JSON views from the model, each file's JSON with its ML data, - // beside an `account.json` with the account's email and user ID. + // ledger (issue #51). Takes the lock in that directory first, unless + // `opts.lockHeld` says the caller holds it, failing at once while another + // backup of it runs (see `runBackup`). Waits for a + // completed refresh, as `fresh()` does, joining one already running, and + // rejects before touching any file but the lock when it fails. Then puts + // pending originals at their save paths as `Photo.download()` does (and + // optional thumbnails) through the content cache and pools, waits for an + // ML data fetch, and rebuilds the derived symlink/JSON views from the + // model, each file's JSON with its ML data, beside an `account.json` with + // the account's email and user ID. // Throws before any network work when no content cache backs the // originals it must fetch. backup(opts?: BackupOptions): Promise<BackupResult> { diff --git a/test/cli/backup.test.ts b/test/cli/backup.test.ts index b30ad5c..6e81524 100644 --- a/test/cli/backup.test.ts +++ b/test/cli/backup.test.ts @@ -43,6 +43,7 @@ import { readlinkSync, rmSync, symlinkSync, + utimesSync, writeFileSync, } from "node:fs"; import { spawnSync } from "node:child_process"; @@ -830,7 +831,81 @@ describe("the refresh before a backup", () => { ); expect(source.originalCalls).toBe(0); - expect(existsSync(outDir)).toBe(false); + // The backup made the directory for its lock, and removed the lock. + expect(readdirSync(outDir)).toEqual([]); + await lib.close(); + }); +}); + +describe("the backup lock", () => { + const lockPath = (outDir: string): string => join(outDir, "backup.lock"); + + it("refuses a second backup of the directory while one runs", async () => { + await fillCache(); + const client = new HeldClient(); + const lib = await openLibrary(stubSource(), client); + const outDir = join(root, "backup"); + // The first backup holds the lock once it starts its refresh, which + // `HeldClient` keeps from finishing. + let refreshing!: () => void; + const started = new Promise<void>((resolve) => { + refreshing = resolve; + }); + const first = lib.backup({ + downloadDirectory: outDir, + onProgress: (msg) => { + if (msg === "Refreshing library...") refreshing(); + }, + }); + await started; + + await expect(lib.backup({ downloadDirectory: outDir })).rejects.toThrow( + `another backup of ${outDir} is running`, + ); + + client.release(); + expect((await first).failed).toBe(0); + await lib.close(); + }); + + it("releases the lock after a backup succeeds", async () => { + const lib = await openLibrary(stubSource()); + const outDir = join(root, "backup"); + + await lib.backup({ downloadDirectory: outDir }); + + expect(existsSync(lockPath(outDir))).toBe(false); + // So the next backup of the directory runs. + expect((await lib.backup({ downloadDirectory: outDir })).skipped).toBe( + 3, + ); + await lib.close(); + }); + + it("releases the lock after a backup fails", async () => { + const lib = await openLibrary(stubSource(), new FailingClient()); + const outDir = join(root, "backup"); + + await expect(lib.backup({ downloadDirectory: outDir })).rejects.toThrow( + "HTTP 401 from server", + ); + + expect(existsSync(lockPath(outDir))).toBe(false); + await lib.close(); + }); + + it("takes over a lock left by a run that was killed", async () => { + const outDir = join(root, "backup"); + // A killed run's lock, last touched a minute ago. + mkdirSync(lockPath(outDir), { recursive: true }); + const minuteAgo = new Date(Date.now() - 60_000); + utimesSync(lockPath(outDir), minuteAgo, minuteAgo); + const lib = await openLibrary(stubSource()); + + const result = await lib.backup({ downloadDirectory: outDir }); + + expect(result.downloaded).toBe(3); + expect(existsSync(lockPath(outDir))).toBe(false); await lib.close(); }); }); diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index 633cc35..b7ce584 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -18,6 +18,7 @@ import { readFileSync, rmSync, statSync, + utimesSync, writeFileSync, } from "node:fs"; import { join } from "node:path"; @@ -703,6 +704,7 @@ describe("backup", () => { " Failed: 0\n", ); expect(stdout.text).toBe(""); + expect(existsSync(join(dir, "backup.lock"))).toBe(false); }); // The backup opens its library with the precache off: it fetches the @@ -792,7 +794,7 @@ describe("backup", () => { expect(code).toBe(1); expect(runText).toBe("quak: HTTP 503 from server\n"); expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n"); - expect(existsSync(dir)).toBe(false); + expect(readdirSync(dir)).toEqual([]); }); // A real saved session, read back by `loadSession`, whose server answers @@ -823,7 +825,38 @@ describe("backup", () => { `quak: the saved session is no longer valid; run "quak login"\n`, ); expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n"); - expect(existsSync(dir)).toBe(false); + expect(readdirSync(dir)).toEqual([]); + }); + + it("exits 2 with one line naming the directory, sending no request, while another backup of it runs", async () => { + const dir = join(root, "backup"); + // The lock another backup holds. Its modification time is set an hour + // ahead, so it stays current however long this test takes. + const lock = join(dir, "backup.lock"); + mkdirSync(lock, { recursive: true }); + const hourAhead = new Date(Date.now() + 3_600_000); + utimesSync(lock, hourAhead, hourAhead); + // A client whose refresh never finishes, so a run that started one + // before it exits would never return. + let requests = 0; + const never = (): Promise<never> => { + requests++; + return new Promise(() => {}); + }; + const client = { + ...fakeClient(), + collectionsSince: never, + filesSince: never, + } as unknown as Client; + + expect(await backupCommand(context(client), dir, {})).toBe(2); + expect(requests).toBe(0); + expect(stderr.text).toBe( + `Starting backup...\nquak: another backup of ${dir} is running\n`, + ); + expect(readdirSync(dir)).toEqual(["backup.lock"]); + // Opening the library would have made its cache directory. + expect(existsSync(join(root, "cache"))).toBe(false); }); }); diff --git a/yarn.lock b/yarn.lock index 4386074..d49442b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -535,6 +535,18 @@ dependencies: undici-types "~6.21.0" +"@types/proper-lockfile@4.1.4": + version "4.1.4" + resolved "https://registry.yarnpkg.com/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz#cd9fab92bdb04730c1ada542c356f03620f84008" + integrity sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ== + dependencies: + "@types/retry" "*" + +"@types/retry@*": + version "0.12.5" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.5.tgz#f090ff4bd8d2e5b940ff270ab39fd5ca1834a07e" + integrity sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw== + "@typescript-eslint/eslint-plugin@8.46.2": version "8.46.2" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz#dc4ab93ee3d7e6c8e38820a0d6c7c93c7183e2dc" @@ -1140,6 +1152,11 @@ globals@^14.0.0: resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== +graceful-fs@^4.2.4: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + graphemer@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" @@ -1414,6 +1431,15 @@ prettier@3.8.1: resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== +proper-lockfile@4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f" + integrity sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA== + dependencies: + graceful-fs "^4.2.4" + retry "^0.12.0" + signal-exit "^3.0.2" + punycode@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" @@ -1429,6 +1455,11 @@ resolve-from@^4.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== + reusify@^1.0.4: version "1.1.0" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" @@ -1502,6 +1533,11 @@ siginfo@^2.0.0: resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== +signal-exit@^3.0.2: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + signal-exit@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" From ddf58af3cc3bce5cddc11b67bf63f9f2fe7a73b0 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 22:47:26 +0200 Subject: [PATCH 21/39] quak backup --verify re-hashes stored originals and downloads again any that do not match (closes #168) `--verify`, or `lib.backup({ verify: true })`, hashes each original already at its save path as the download check does, streamed, a live photo as `<imageHash>:<videoHash>`. A mismatch is logged, removed and put back in the same run, and what is put back is hashed too, since a copy from the content cache is not checked; one that still does not match, or a failed fetch, goes into `failures.json`. A file with no recorded hash counts as unchecked. The result, the summary and `--json` gain `verified`, `mismatched` and `unchecked`. Judgement call: a stored original that cannot be read for hashing is recorded as failed and left in place. Judgement call: a copy put back that still does not match stays at its save path and is not counted as downloaded. Judgement call: the summary prints the three counts only with `--verify`. Model: opus-5-5 --- README.md | 29 ++++- TODO.md | 10 ++ bin/quak.ts | 6 +- src/backup.ts | 108 +++++++++++++++++- src/cli-commands.ts | 8 +- test/cli/backup.test.ts | 234 ++++++++++++++++++++++++++++++++++++++ test/cli/commands.test.ts | 58 +++++++++- test/live-photo.ts | 3 +- 8 files changed, 445 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 138caf2..40dd09e 100644 --- a/README.md +++ b/README.md @@ -549,7 +549,7 @@ quak collections [--json] list all collections quak files --collection <id> [--json] list files in a collection quak get <fileID> [--out path] [--collection] download and decrypt a file quak get-thumb <fileID> [--out] [--collection] download and decrypt a thumbnail -quak backup <dir> [--json] full incremental backup +quak backup <dir> [--json] [--verify] full incremental backup quak backup-metadata <dir> [--exif] dump the metadata quak keeps as JSON quak helper list-missing-thumbnails [--json] find files with missing thumbnails quak helper fix-missing-thumbnails [--file ids] [--json] generate + upload missing thumbnails @@ -582,6 +582,10 @@ reads no tag from is recorded, base64, as `exifRaw`, with the reason in `exifError`. `collections`, `files`, `backup`, `helper list-missing-thumbnails` and `helper fix-missing-thumbnails` take `--json` for machine-readable output. +`backup --verify` also hashes the originals already in the backup and replaces +any that do not match the content hash Ente records; one it cannot replace goes +into `failures.json` (see "Backup layout"). + `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 `mlDataError` field instead of `mlData`, and the dump goes on. The exit code is @@ -715,6 +719,25 @@ if any files failed. `quak backup` opens its library with the thumbnail and originals precache off, so the only file content it fetches is the originals the backup stores. +With `--verify`, or `lib.backup({ verify: true })`, a run also hashes each +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 +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 +line naming the file, deleted (a live photo's image and video both), and put +back in the same run like a missing one: downloaded, or copied from the cache if +the cache holds it. What is put back is hashed too, because a copy from the +cache is not checked as a download is. If it still does not match, it stays at +its save path and the file goes into `failures.json`, as it does when the +download fails. A file whose metadata records no hash is left as it is and +counted as unchecked. A stored original that cannot be read is left as it is and +counts as failed. The summary and `--json` add the counts `verified`, +`mismatched` and `unchecked`, all of originals that were already stored; one +first downloaded in this run is in none of them. A mismatch that was put back +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 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 @@ -945,8 +968,8 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange` then counts it as present; one the cache already held is copied from there. `BackupOptions`: `downloadDirectory` (falls back to the library's), `includeOriginals` (default `true`), `includeThumbnails` (default `false`), - `onlyAlbumNames`, `onProgress`, and `lockHeld` (default `false`). See Backup - layout above for the tree it writes. + `onlyAlbumNames`, `verify` (default `false`), `onProgress`, and `lockHeld` + (default `false`). See Backup layout above for the tree it writes. ### Request pools diff --git a/TODO.md b/TODO.md index 86de9b9..fc9d5e0 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,16 @@ declares one. # Completed Steps +- 2026-10-06: `quak backup --verify` and `lib.backup({ verify: true })` hash + each original already at its save path as the download check does, streamed, a + live photo as `<imageHash>:<videoHash>` (issue 168). One that does not match + the content hash its metadata records is logged, removed (both files of a live + photo) and put back in the same run, downloaded or copied from the cache, and + what is put back is hashed too. One that still does not match, or whose + download fails, goes into `failures.json`. One with no recorded hash is left + alone. The result, `--json` and the summary gain `verified`, `mismatched` and + `unchecked`. Without `--verify` nothing is hashed. + - 2026-10-06: Two backups of the same directory never run at once (issue 169). `lib.backup()` takes a lock, `backup.lock` in its download directory, made with `proper-lockfile`, before its refresh, and removes it when it ends, diff --git a/bin/quak.ts b/bin/quak.ts index f974348..77a1a95 100644 --- a/bin/quak.ts +++ b/bin/quak.ts @@ -130,9 +130,13 @@ program ) .argument("<dir>", "Output directory") .option("--json", "Print result as JSON instead of human-readable summary") + .option( + "--verify", + "Re-hash stored originals and download again any that do not match", + ) // A backup usually runs from cron with nobody watching, so every request // it makes retries for longer than the other commands' requests do. - .action((dir: string, opts: { json?: boolean }) => + .action((dir: string, opts: { json?: boolean; verify?: boolean }) => run( backupCommand( { diff --git a/src/backup.ts b/src/backup.ts index bd86910..184d466 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -38,6 +38,11 @@ // why they could not be read), so that a run does not read every stored // original again; a sidecar without them gets them read from the original. // +// 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 +// metadata records is removed and fetched again in the same run. What is put +// back is hashed too, and recorded as failed if it still does not match. +// // 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 // failed is caught, recorded in `failures.json` with a classification, a @@ -50,6 +55,7 @@ // code. import { + createReadStream, lstatSync, mkdirSync, readdirSync, @@ -65,6 +71,12 @@ import { readFile } from "node:fs/promises"; import { dirname, extname, join, relative, resolve } from "node:path"; import lockfile from "proper-lockfile"; +import { + chunkHashFinal, + chunkHashInit, + chunkHashUpdate, + init, +} from "./crypto/index.js"; import { removeLeftoverTempFiles } from "./download/index.js"; import { sanitizeFileName, withExtension } from "./filename.js"; import { @@ -92,6 +104,9 @@ export interface BackupOptions { includeThumbnails?: boolean; // Restrict the backup to albums with these names; others are left untouched. onlyAlbumNames?: string[]; + // Hash each original already at its save path, and fetch again any whose + // bytes do not match the content hash its metadata records. Default false. + verify?: boolean; onProgress?: ProgressCallback; // The caller already holds the lock in `downloadDirectory`, taken with // `lockBackupDirectory`, and releases it itself, so the backup does not @@ -113,6 +128,13 @@ export interface BackupResult { downloaded: number; // Originals already at their save path and left untouched. skipped: number; + // With `verify`, the originals already at their save path whose hash + // matched, those whose hash did not (each removed and fetched again), and + // those whose metadata records no hash (left as they are). All three are + // zero without `verify`. + verified: number; + mismatched: number; + unchecked: number; // Files with an unresolved failure after this run (the ledger size); the // CLI exits non-zero while this is above zero. A file can be both // downloaded and failed if its bytes landed but its symlink did not. @@ -369,6 +391,26 @@ const saveLedger = (path: string, ledger: Map<number, FailureEntry>): void => { ); }; +// The content hash of the original stored at `stored`, computed as the download +// check computes it: over each file's bytes, read in chunks, and for a live +// photo `<imageHash>:<videoHash>`. +const storedHash = async (stored: { + path: string; + videoPath?: string; +}): Promise<string> => { + await init(); + const hashFile = async (path: string): Promise<string> => { + const state = chunkHashInit(); + for await (const chunk of createReadStream(path)) { + chunkHashUpdate(state, chunk as Buffer); + } + return chunkHashFinal(state); + }; + const hash = await hashFile(stored.path); + if (stored.videoPath === undefined) return hash; + return `${hash}:${await hashFile(stored.videoPath)}`; +}; + // A file's EXIF, XMP and dimensions as its JSON holds them: what // `extractImageMetadata` found in its original, or why the original could not // be read. @@ -468,6 +510,7 @@ const runLockedBackup = async ( const includeThumbnails = opts.includeThumbnails ?? false; const log = opts.onProgress ?? (() => {}); const only = opts.onlyAlbumNames ? new Set(opts.onlyAlbumNames) : undefined; + const verify = opts.verify ?? false; log("Refreshing library..."); await lib.refresh(); @@ -526,6 +569,9 @@ const runLockedBackup = async ( const storedThisRun = new Set<number>(); let downloaded = 0; let skipped = 0; + let verified = 0; + let mismatched = 0; + let unchecked = 0; const recordFailure = ( file: EnteFile, @@ -557,10 +603,47 @@ const runLockedBackup = async ( // Phase 1: get the bytes. Put each pending original at its save path // through the content cache/pools, as `Photo.download()` does, and fetch - // the optional thumbnails; a present file is left as is. + // 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 + // its metadata records is removed and fetched like a missing one. One + // that cannot be read is recorded as failed and left where it is. if (includeOriginals) { for (const [fileID, file] of distinct) { - if (storedAtSavePath(downloadDirectory, file) !== undefined) { + let stored = storedAtSavePath(downloadDirectory, file); + let mismatch = false; + if (stored !== undefined && verify) { + try { + if (file.metadata.hash === undefined) { + unchecked++; + } else if ( + (await storedHash(stored)) === file.metadata.hash + ) { + verified++; + } else { + log( + `MISMATCH original ${file.metadata.title} (${fileID}): its bytes do not match its content hash`, + ); + mismatched++; + mismatch = true; + rmSync(stored.path); + if (stored.videoPath !== undefined) { + rmSync(stored.videoPath); + } + stored = undefined; + } + } catch (err) { + log( + `FAILED verifying original ${file.metadata.title}: ${errorMessage(err)}`, + ); + recordFailure( + file, + collectionName.get(file.collectionID) ?? "", + err, + ); + continue; + } + } + if (stored !== undefined) { skipped++; continue; } @@ -569,10 +652,24 @@ const runLockedBackup = async ( // A fetched original is written straight to its save path (a // live photo beside it); only one that was already cached // elsewhere is copied. - await placeOriginal(downloadDirectory, file, (dest) => - lib.original(fileID, dest), + const placed = await placeOriginal( + downloadDirectory, + file, + (dest) => lib.original(fileID, dest), ); storedThisRun.add(fileID); + // A copy from the cache is not checked as a download is and can + // hold the same bad bytes, so what is put back after a + // mismatch is hashed too. A bad copy stays where it is and the + // file fails. + if ( + mismatch && + (await storedHash(placed)) !== file.metadata.hash + ) { + throw new Error( + "the original put back does not match its content hash either", + ); + } downloaded++; } catch (err) { log( @@ -735,6 +832,9 @@ const runLockedBackup = async ( totalFiles: distinct.size, downloaded, skipped, + verified, + mismatched, + unchecked, failed: ledger.size, errors, }; diff --git a/src/cli-commands.ts b/src/cli-commands.ts index d876d51..f519414 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -402,7 +402,7 @@ export const backupMetadataCommand = async ( export const backupCommand = async ( ctx: CliContext, dir: string, - opts: { json?: boolean }, + opts: { json?: boolean; verify?: boolean }, ): Promise<number> => { await init(); const client = requireSession(ctx); @@ -433,6 +433,7 @@ export const backupCommand = async ( const result = await lib.backup({ downloadDirectory: dir, lockHeld: true, + verify: opts.verify, onProgress: (msg) => { if (!opts.json) ctx.stderr.write(msg + "\n"); }, @@ -445,6 +446,11 @@ export const backupCommand = async ( ctx.stderr.write(` Total files: ${result.totalFiles}\n`); ctx.stderr.write(` Downloaded: ${result.downloaded}\n`); ctx.stderr.write(` Skipped: ${result.skipped}\n`); + if (opts.verify) { + ctx.stderr.write(` Verified: ${result.verified}\n`); + ctx.stderr.write(` Mismatched: ${result.mismatched}\n`); + ctx.stderr.write(` Unchecked: ${result.unchecked}\n`); + } ctx.stderr.write(` Failed: ${result.failed}\n`); if (result.errors.length > 0) { ctx.stderr.write("\nFailed files:\n"); diff --git a/test/cli/backup.test.ts b/test/cli/backup.test.ts index 6e81524..977a584 100644 --- a/test/cli/backup.test.ts +++ b/test/cli/backup.test.ts @@ -62,6 +62,7 @@ import { HEIC_WITH_EXIF } from "../exif-heic.js"; import { JPEG_WITH_EXIF } from "../exif-jpeg.js"; import { asLivePhoto, + blake2b, cdnSource, IMAGE, livePhotoHash, @@ -1271,6 +1272,181 @@ describe("image metadata in each file's JSON", () => { }); }); +// With `verify`, each original already stored is hashed, and one that does not +// match the content hash its metadata records is downloaded again. +describe("backup with verify", () => { + // MockClient's files, each recording the hash of what `stubSource` writes + // for it, except diagram.png (200), which records none. + class HashedClient extends MockClient { + override async filesSince(args: { + collectionID: number; + }): Promise<FilesPage> { + const page = await super.filesSince(args); + const files = page.files.map((f) => + f.id === 200 + ? f + : { + ...f, + metadata: { + ...f.metadata, + hash: blake2b(Buffer.alloc(SIZE_BY_ID[f.id]!)), + }, + }, + ); + return { ...page, files }; + } + } + + // A backup of the account in `root/backup`, the library that made it, and + // the source it fetched from. + const backedUp = async (): Promise<{ + lib: Library; + source: StubSource; + outDir: string; + }> => { + const source = stubSource(); + const lib = await openLibrary(source, new HashedClient()); + const outDir = join(root, "backup"); + await lib.backup({ downloadDirectory: outDir }); + return { lib, source, outDir }; + }; + + // What `stubSource` writes for beach.jpg (100), and other bytes of the + // same length. + const good = Buffer.alloc(SIZE_BY_ID[100]!); + const corrupt = Buffer.alloc(SIZE_BY_ID[100]!, 1); + + it("leaves an original that matches its hash, and one with no hash, as they are", async () => { + const { lib, source, outDir } = await backedUp(); + const calls = source.originalCalls; + + const result = await lib.backup({ + downloadDirectory: outDir, + verify: true, + }); + + expect(result).toMatchObject({ + downloaded: 0, + skipped: 3, + verified: 2, + mismatched: 0, + unchecked: 1, + failed: 0, + }); + expect(source.originalCalls).toBe(calls); + await lib.close(); + }); + + it("downloads again an original that does not match its hash", async () => { + const { lib, outDir } = await backedUp(); + writeFileSync(saved(outDir, "100.jpg"), corrupt); + const log: string[] = []; + + const result = await lib.backup({ + downloadDirectory: outDir, + verify: true, + onProgress: (msg) => log.push(msg), + }); + + expect(result).toMatchObject({ + downloaded: 1, + skipped: 2, + verified: 1, + mismatched: 1, + unchecked: 1, + failed: 0, + }); + expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(good); + expect(log.filter((msg) => msg.startsWith("MISMATCH"))).toEqual([ + "MISMATCH original beach.jpg (100): its bytes do not match its content hash", + ]); + await lib.close(); + }); + + it("records a failed download in failures.json, with the original removed", async () => { + const { lib, source, outDir } = await backedUp(); + writeFileSync(saved(outDir, "100.jpg"), corrupt); + source.failID = 100; + + const result = await lib.backup({ + downloadDirectory: outDir, + verify: true, + }); + + expect(result).toMatchObject({ + downloaded: 0, + mismatched: 1, + failed: 1, + }); + expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]); + expect(existsSync(saved(outDir, "100.jpg"))).toBe(false); + await lib.close(); + }); + + it("records in failures.json an original the cache puts back with the same bad bytes", async () => { + const lib = await openLibrary(stubSource(), 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 result = await lib.backup({ + downloadDirectory: outDir, + verify: true, + }); + + expect(result).toMatchObject({ + downloaded: 0, + mismatched: 1, + failed: 1, + }); + expect(result.errors.map((e) => e.error)).toEqual([ + "the original put back does not match its content hash either", + ]); + expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]); + expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(corrupt); + await lib.close(); + }); + + it("hashes nothing without verify", async () => { + const { lib, outDir } = await backedUp(); + writeFileSync(saved(outDir, "100.jpg"), corrupt); + + const result = await lib.backup({ downloadDirectory: outDir }); + + expect(result).toMatchObject({ + downloaded: 0, + skipped: 3, + verified: 0, + mismatched: 0, + unchecked: 0, + failed: 0, + }); + expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(corrupt); + await lib.close(); + }); + + // Root ignores file permissions, so this fails when run as root. The + // `test` phase of the `Dockerfile` runs as the `node` user. + it("records an original it cannot read as failed, and leaves it", async () => { + const { lib, outDir } = await backedUp(); + const original = saved(outDir, "100.jpg"); + chmodSync(original, 0o000); + + const result = await lib + .backup({ downloadDirectory: outDir, verify: true }) + .finally(() => chmodSync(original, 0o600)); + + expect(result).toMatchObject({ downloaded: 0, verified: 1, failed: 1 }); + expect(result.errors.map((e) => e.fileID)).toEqual([100]); + expect(result.errors[0]!.error).toMatch(/EACCES/); + expect(readFileSync(original)).toEqual(good); + await lib.close(); + }); +}); + // Every entry under collections/, one level of directories deep, with each // symlink's target. const tree = (outDir: string): string[] => { @@ -1766,6 +1942,64 @@ describe("backup of live photos", () => { }, ); + it("verifies a live photo's image and video together, and downloads it again when one does not match", async () => { + const { file: live, body } = await asLivePhoto( + file(500, 10, "IMG_0500.HEIC"), + ); + const lib = await open([live], new Map([[500, body]])); + const outDir = join(root, "backup"); + await lib.backup({ downloadDirectory: outDir }); + + const good = await lib.backup({ + downloadDirectory: outDir, + verify: true, + }); + expect(good).toMatchObject({ skipped: 1, verified: 1, mismatched: 0 }); + + const video = saved(outDir, "500.mov"); + writeFileSync(video, "another few seconds of video"); + const bad = await lib.backup({ + downloadDirectory: outDir, + verify: true, + }); + + expect(bad).toMatchObject({ + downloaded: 1, + verified: 0, + mismatched: 1, + failed: 0, + }); + expect(readFileSync(saved(outDir, "500.heic"))).toEqual( + Buffer.from(IMAGE), + ); + expect(readFileSync(video)).toEqual(Buffer.from(VIDEO)); + expect(tree(outDir)).toEqual(linked); + await lib.close(); + }); + + 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( + file(500, 10, "IMG_0500.HEIC"), + ); + const bodies = new Map([[500, body]]); + const lib = await open([live], bodies); + const outDir = join(root, "backup"); + await lib.backup({ downloadDirectory: outDir }); + writeFileSync(saved(outDir, "500.mov"), "another few seconds of video"); + bodies.delete(500); + + const result = await lib.backup({ + downloadDirectory: outDir, + verify: true, + }); + + expect(result).toMatchObject({ mismatched: 1, failed: 1 }); + expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]); + expect(existsSync(saved(outDir, "500.heic"))).toBe(false); + expect(existsSync(saved(outDir, "500.mov"))).toBe(false); + await lib.close(); + }); + it("serves a live photo the backup stored to a library reading the backup", async () => { const { file: live, body } = await asLivePhoto( file(500, 10, "IMG_0500.HEIC"), diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index b7ce584..8e068c8 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -53,13 +53,14 @@ import { import { run } from "../../src/cli-run.js"; import { loadSession } from "../../src/cli-session.js"; import type { Client, ClientSnapshot, LoginOptions } from "../../src/client.js"; -import 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 { init, toBase64 } from "../../src/crypto/index.js"; import { defaultCacheDirectory } from "../../src/library/index.js"; import { HEIC_WITH_EXIF } from "../exif-heic.js"; import { asLivePhoto, + blake2b, cdnSource, IMAGE, livePhotoHash, @@ -744,6 +745,61 @@ describe("backup", () => { expect(stderr.text).toBe("Starting backup...\n"); }); + it("--verify downloads again an original that does not match its hash, prints the counts, and exits 0", async () => { + // Each file records the hash of the original the fake writes for it. + const client = { + ...fakeClient(), + filesSince: async (args: { collectionID: number }) => ({ + files: (FILES[args.collectionID] ?? []).map((f) => ({ + ...f, + metadata: { + ...f.metadata, + hash: blake2b(Buffer.alloc(7, f.id & 0xff)), + }, + })), + deleted: [], + cursor: 1, + }), + } as unknown as Client; + const ctx = context(client); + const dir = join(root, "backup"); + expect(await backupCommand(ctx, dir, {})).toBe(0); + writeFileSync(savePath(dir, FILES[1]![0]!), "corrupt"); + + expect(await backupCommand(ctx, dir, { verify: true })).toBe(0); + + expect(stderr.text).toContain( + "MISMATCH original beach.jpg (100): its bytes do not match its content hash\n", + ); + expect(stderr.text).toContain( + " Downloaded: 1\n" + + " Skipped: 2\n" + + " Verified: 2\n" + + " Mismatched: 1\n" + + " Unchecked: 0\n" + + " Failed: 0\n", + ); + }); + + it("--verify --json adds the verified, mismatched and unchecked counts", async () => { + const dir = join(root, "backup"); + expect(await backupCommand(context(), dir, {})).toBe(0); + + const code = await backupCommand(context(), dir, { + verify: true, + json: true, + }); + + expect(code).toBe(0); + expect(JSON.parse(stdout.text)).toMatchObject({ + skipped: 3, + verified: 0, + mismatched: 0, + unchecked: 3, + failed: 0, + }); + }); + it("exits 1 and lists each file when the ML data fetch fails", async () => { const client = { ...fakeClient(), diff --git a/test/live-photo.ts b/test/live-photo.ts index 1e3f17e..a966ff0 100644 --- a/test/live-photo.ts +++ b/test/live-photo.ts @@ -30,7 +30,8 @@ export const livePhotoZip = ( }, ): Uint8Array => zipSync(entries); -const blake2b = (bytes: Uint8Array): string => +// The content hash Ente's clients record for an original's bytes. +export const blake2b = (bytes: Uint8Array): string => createHash("blake2b512").update(bytes).digest("base64"); // The hash Ente's clients record for a live photo: the unkeyed BLAKE2b-512 of From 2402ec97f72d5d4af44c2709b60b5960d38985b0 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 7 Oct 2026 00:47:37 +0200 Subject: [PATCH 22/39] README: running quak backup from cron, and its exit codes (closes #170) A new README section shows how to run quak backup unattended: log in once as the job's user, a crontab with a backup every night and --verify on Sundays, output appended to a log file, and the HOME and XDG_DATA_HOME the job needs to find the saved session. A table gives each exit code as the code returns it, and says which failures the next run retries. The introduction now lists everything the backup keeps for each file. It, the backup layout tree and the lib.backup() entry say EXIF and XMP are kept for an image only, and dimensions for a JPEG only. Judgement call: the --verify run takes Sunday's slot rather than a second job that night, since an overlapping run would exit 2. Model: opus-5-5 --- README.md | 92 +++++++++++++++++++++++++++++++++++++++++++++++-------- TODO.md | 9 ++++++ 2 files changed, 88 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 40dd09e..62d3697 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,12 @@ account into a deduplicated local directory tree, skipping files that already exist on disk and continuing past individual download failures instead of crashing. For each file it persists the basic metadata fields quak keeps (title, file type, creation and modification time, latitude, longitude, content hash), -and the private and public magic metadata in full. A helper subcommand can -detect and regenerate missing thumbnails, encrypting and uploading them back to -the server. +its update time, the private and public magic metadata in full, Ente's ML data +for it when there is any, and, for an image (for a live photo, its image), its +original's EXIF and XMP, with its dimensions for a JPEG only; for a video it +keeps none of these three. It runs unattended from cron (see "Running the backup +from cron"). A helper subcommand can detect and regenerate missing thumbnails, +encrypting and uploading them back to the server. ## Getting Started @@ -80,6 +83,64 @@ await lib.close(); The lower-level `Client` (login, session serialization, and the raw enumeration/download calls) is exported too and documented under Design below. +## Running the backup from cron + +`quak backup` never prompts, so cron can run it. `make install` builds quak as a +single binary and copies it to `~/bin/quak`. Log in once with it, as the user +the cron job will run as; the session is saved in that user's data directory +(see "Session handling"): + +```bash +~/bin/quak login +``` + +Then add the backup to that user's crontab with `crontab -e`. These two lines +back up the account to `~/photos-backup` at 03:30 every night, with `--verify` +on Sundays, and append all output to `~/quak-backup.log`: + +``` +30 3 * * 1-6 $HOME/bin/quak backup $HOME/photos-backup >> $HOME/quak-backup.log 2>&1 +30 3 * * 0 $HOME/bin/quak backup --verify $HOME/photos-backup >> $HOME/quak-backup.log 2>&1 +``` + +A run with `--verify` does all a plain run does, and also checks each original +already in the backup against the content hash Ente records for it, and replaces +any that do not match (see "Backup layout"). Sunday's run is the `--verify` one, +not a second job that night, because a backup that starts while another backup +of the same directory is running exits 2 without backing anything up. + +Cron runs the job with a short `PATH`, usually `/usr/bin:/bin`, so the crontab +names quak by its full path. To find the saved session, quak needs the same +`HOME` as when you logged in, which cron sets from the password file, and on +Linux the same `XDG_DATA_HOME`: quak looks for the session in +`$XDG_DATA_HOME/quak`, or in `~/.local/share/quak` when that is not set. Cron +does not set `XDG_DATA_HOME`, so if your login shell does, set it at the top of +the crontab too. Cron does not expand variables in such a line, so give the full +path: + +``` +XDG_DATA_HOME=/home/you/.data +``` + +On macOS the session is in `~/Library/Application Support/quak`, and only `HOME` +matters. + +### Exit codes + +| Code | Meaning | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | The backup is complete: every original is at its save path, and no file failed. | +| `1` | The run finished with files in `failures.json`, or it stopped on another error, printed as one line `quak: <message>`. The next run tries again. | +| `2` | Another backup of the same directory is running. This run sent no request and changed nothing. | +| `3` | There is no usable session: none is saved, the saved one is corrupt, or the server no longer accepts it. Run `quak login` as the user the cron job runs as. | + +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 original that a +`--verify` run could not read, or put back with bytes that still do not match, +stays at its save path. Only a later `--verify` run checks it again: a run +without `--verify` leaves it as it is and takes the file out of `failures.json`, +so that run can exit 0. + ## Examples `examples/download-albums.ts` downloads every album's photos and their metadata @@ -615,8 +676,11 @@ the smallest does not. below) YYYY-MM-DD.<fileID>.json the file's basic metadata fields quak keeps, its update time, its private and - public magic metadata, its ML data, and - its original's EXIF, XMP and dimensions + public magic metadata, its ML data, and, + for an image (for a live photo, its + image), its original's EXIF and XMP, with + dimensions for a JPEG only; none of these + three for a video YYYY-MM-DD.<fileID>.livephoto.json which of a live photo's two files is which collections/ @@ -962,14 +1026,16 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange` `fresh()` does, puts every in-scope original not already at its save path there as `photo.download()` does (and, with `includeThumbnails`, fetches thumbnails) through the content cache, waits for an ML data fetch, and - rebuilds the on-disk backup tree, each file's JSON with its ML data and its - original's EXIF, XMP and dimensions, with a durable failure ledger. A fetched - original is written straight to its save path and not into the cache, which - then counts it as present; one the cache already held is copied from there. - `BackupOptions`: `downloadDirectory` (falls back to the library's), - `includeOriginals` (default `true`), `includeThumbnails` (default `false`), - `onlyAlbumNames`, `verify` (default `false`), `onProgress`, and `lockHeld` - (default `false`). See Backup layout above for the tree it writes. + rebuilds the on-disk backup tree with a durable failure ledger. Each file's + JSON holds its ML data and, for an image (for a live photo, its image), its + original's EXIF and XMP, with its dimensions for a JPEG only; a video's JSON + holds none of these three. A fetched original is written straight to its save + path and not into the cache, which then counts it as present; one the cache + already held is copied from there. `BackupOptions`: `downloadDirectory` (falls + back to the library's), `includeOriginals` (default `true`), + `includeThumbnails` (default `false`), `onlyAlbumNames`, `verify` (default + `false`), `onProgress`, and `lockHeld` (default `false`). See Backup layout + above for the tree it writes. ### Request pools diff --git a/TODO.md b/TODO.md index fc9d5e0..2ab03da 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,15 @@ declares one. # Completed Steps +- 2026-10-06: The README says how to run `quak backup` from cron (issue 170): + log in once as the job's user, a crontab with a backup every night and + `--verify` on Sundays appending to a log file, and the `HOME` and + `XDG_DATA_HOME` the job needs to find the saved session. A table gives each + exit code: 0, the backup is complete; 1, files are in `failures.json` or + another error stopped the run; 2, another backup of the directory is running; + 3, there is no usable session. The introduction lists everything the backup + keeps for each file. + - 2026-10-06: `quak backup --verify` and `lib.backup({ verify: true })` hash each original already at its save path as the download check does, streamed, a live photo as `<imageHash>:<videoHash>` (issue 168). One that does not match From ab28d86aaaa7753ceaf881210204f11b79fd199f Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 14 Oct 2026 03:06:35 +0200 Subject: [PATCH 23/39] An ID on the command line is decimal digits only (closes #185) files --collection, get, get-thumb and helper fix-missing-thumbnails --file read an ID with Number(), so 0x10 meant 16, 1e3 meant 1000 and an empty string meant 0, and --file dropped an ID it could not read. parseID in src/cli-commands.ts now takes an ID only when it is decimal digits and a safe integer; anything else prints "Invalid file ID: ARG" or "Invalid collection ID: ARG" and the command exits 1. The fixer checks every --file ID before it opens the library, so one bad ID fails the whole command before any request. Model: opus-5-5 --- README.md | 7 ++ TODO.md | 9 +++ src/cli-commands.ts | 47 +++++++------ test/cli/commands.test.ts | 138 +++++++++++++++++++++++++++++++++++--- 4 files changed, 171 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 62d3697..32a0b9d 100644 --- a/README.md +++ b/README.md @@ -626,6 +626,13 @@ exits 1, or 3 when the server no longer accepts the saved session (see "Session handling"). `--cache-dir` overrides where the cache lives; without it each account gets its own directory under the per-user cache path. +An ID given to `files --collection`, `get`, `get-thumb` or +`helper fix-missing-thumbnails --file` is written in decimal digits, no larger +than 9007199254740991. Anything else, such as `0x10`, `1e3` or `-1`, is refused +before any request with one line, `Invalid file ID: <arg>` or +`Invalid collection ID: <arg>`, and exit status 1. One bad `--file` ID refuses +the whole command, so nothing is fixed. + `get` and `get-thumb` resolve the file by ID directly, so `--collection` is 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 diff --git a/TODO.md b/TODO.md index 2ab03da..bacb24a 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,15 @@ declares one. # Completed Steps +- 2026-10-14: `files --collection`, `get`, `get-thumb` and + `helper fix-missing-thumbnails --file` take an ID only when it is written in + decimal digits and is a safe integer (issue 185). Before, `Number()` read + `0x10` as 16, `1e3` as 1000 and an empty string as 0, and `--file` dropped an + ID it could not read without a word. Anything else now prints + `Invalid file ID: <arg>` or `Invalid collection ID: <arg>` and exits 1 before + any request; one bad `--file` ID fails the whole command. `parseID` in + `src/cli-commands.ts` does the check for all four. + - 2026-10-06: The README says how to run `quak backup` from cron (issue 170): log in once as the job's user, a crontab with a backup every night and `--verify` on Sundays appending to a log file, and the `HOME` and diff --git a/src/cli-commands.ts b/src/cli-commands.ts index f519414..5f8b0b6 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -97,6 +97,20 @@ const requireSession = (ctx: CliContext): Client | undefined => { return client; }; +// `text` as an ID, or undefined after telling the user it is not one. An ID is +// written in decimal digits and is a safe integer; `Number()` alone would read +// `0x10` as 16, `1e3` as 1000 and "" as 0. +const parseID = ( + ctx: CliContext, + kind: "file" | "collection", + text: string, +): number | undefined => { + const id = Number(text); + if (/^[0-9]+$/.test(text) && Number.isSafeInteger(id)) return id; + ctx.stderr.write(`Invalid ${kind} ID: ${text}\n`); + return undefined; +}; + // A library client that omits `fetchMLData`, so the point commands below do not // kick the library's background ML backfill: they read metadata, or fetch one // file's content, and exit. `backup` and `backup-metadata` handle ML on their @@ -247,11 +261,8 @@ export const filesCommand = async ( await init(); const client = requireSession(ctx); if (!client) return 3; - const collectionID = Number(opts.collection); - if (!Number.isFinite(collectionID)) { - ctx.stderr.write("Invalid collection ID\n"); - return 1; - } + const collectionID = parseID(ctx, "collection", opts.collection); + if (collectionID === undefined) return 1; const lib = await openReadLibrary(ctx, client); try { @@ -289,11 +300,8 @@ export const getCommand = async ( await init(); const client = requireSession(ctx); if (!client) return 3; - const fileID = Number(fileIDStr); - if (!Number.isFinite(fileID)) { - ctx.stderr.write("Invalid file ID\n"); - return 1; - } + const fileID = parseID(ctx, "file", fileIDStr); + if (fileID === undefined) return 1; const lib = await openReadLibrary(ctx, client); try { @@ -347,11 +355,8 @@ export const getThumbCommand = async ( await init(); const client = requireSession(ctx); if (!client) return 3; - const fileID = Number(fileIDStr); - if (!Number.isFinite(fileID)) { - ctx.stderr.write("Invalid file ID\n"); - return 1; - } + const fileID = parseID(ctx, "file", fileIDStr); + if (fileID === undefined) return 1; const lib = await openReadLibrary(ctx, client); try { @@ -516,15 +521,19 @@ export const fixMissingThumbnailsCommand = async ( await init(); const client = requireSession(ctx); if (!client) return 3; + // One bad --file ID fails the whole command, before anything is fetched. + let fileIDs: number[] = []; + for (const text of opts.file ?? []) { + const fileID = parseID(ctx, "file", text); + if (fileID === undefined) return 1; + fileIDs.push(fileID); + } const lib = await openReadLibrary(ctx, client); try { // Refresh first so files added since the cache was written are found; // a failed refresh throws. await lib.fresh(); - let fileIDs: number[]; - if (opts.file && opts.file.length > 0) { - fileIDs = opts.file.map(Number).filter(Number.isFinite); - } else { + if (fileIDs.length === 0) { ctx.stderr.write("Scanning for missing thumbnails...\n"); const missing = await listMissingThumbnails(lib, client, (msg) => { if (!opts.json) ctx.stderr.write(msg + "\n"); diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index 8e068c8..09189a5 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -511,11 +511,6 @@ describe("files", () => { expect(await filesCommand(context(), { collection: "9" })).toBe(1); expect(stderr.text).toBe("Collection 9 not found\n"); }); - - it("exits 1 for a collection ID that is not a number", async () => { - expect(await filesCommand(context(), { collection: "abc" })).toBe(1); - expect(stderr.text).toBe("Invalid collection ID\n"); - }); }); describe("get and get-thumb", () => { @@ -546,12 +541,6 @@ describe("get and get-thumb", () => { expect(stderr.text).toBe("File 999 not found\n"); expect(existsSync(out)).toBe(false); }); - - it("both exit 1 for a file ID that is not a number", async () => { - expect(await getCommand(context(), "abc", {})).toBe(1); - expect(await getThumbCommand(context(), "abc", {})).toBe(1); - expect(stderr.text).toBe("Invalid file ID\nInvalid file ID\n"); - }); }); // A live photo, which Ente stores as one ZIP, is written as its image and its @@ -1035,3 +1024,130 @@ describe("backup-metadata and the thumbnail helpers refresh first", () => { expect(stdout.text).toBe(""); }); }); + +// `files --collection`, `get`, `get-thumb` and `helper fix-missing-thumbnails +// --file` take an ID only when it is written in decimal digits and is a safe +// integer. `Number()` alone reads `0x10` as 16, `1e3` as 1000 and an empty +// string as 0, so the command would act on a file the user never named. +// Anything else is refused with one line naming it, and exit 1, before any +// request. +// +// These run on a real client, read back from a saved session, over a fake Ente +// server holding an empty account, which records each request it receives. With +// a good ID the command goes on to ask the server, and finds nothing there. +describe("an ID given on the command line", () => { + const NOT_IDS = [ + "0x10", + "1e3", + "1.5", + "-1", + "", + "abc", + // One past Number.MAX_SAFE_INTEGER: from here on a number cannot + // hold every integer, so two IDs could read as one. + "9007199254740992", + ]; + + const requests: Request[] = []; + + const emptyAccount = (): CliContext => { + const ctx = context(); + const key = toBase64(new Uint8Array(32)); + saveSession(ctx.sessionDir, { + email: "cli@example.com", + userID: USER_ID, + token: "token", + masterKey: key, + secretKey: key, + publicKey: key, + }); + return { + ...ctx, + loadSession: (path) => + loadSession(path, { + fetch: async (url, init) => { + requests.push(new Request(url, init)); + return new Response( + JSON.stringify({ collections: [] }), + { headers: { "content-type": "application/json" } }, + ); + }, + }), + }; + }; + + beforeEach(() => { + requests.length = 0; + }); + + it("files looks up a decimal collection ID", async () => { + const ctx = emptyAccount(); + expect(await filesCommand(ctx, { collection: "1" })).toBe(1); + expect(stderr.text).toBe("Collection 1 not found\n"); + expect(requests).not.toHaveLength(0); + }); + + it.each(NOT_IDS)( + "files refuses --collection %j before any request", + async (arg) => { + const ctx = emptyAccount(); + expect(await filesCommand(ctx, { collection: arg })).toBe(1); + expect(stderr.text).toBe(`Invalid collection ID: ${arg}\n`); + expect(stdout.text).toBe(""); + expect(requests).toHaveLength(0); + }, + ); + + it("get looks up a decimal file ID", async () => { + expect(await getCommand(emptyAccount(), "100", {})).toBe(1); + expect(stderr.text).toBe("File 100 not found\n"); + expect(requests).not.toHaveLength(0); + }); + + it.each(NOT_IDS)("get refuses %j before any request", async (arg) => { + expect(await getCommand(emptyAccount(), arg, {})).toBe(1); + expect(stderr.text).toBe(`Invalid file ID: ${arg}\n`); + expect(stdout.text).toBe(""); + expect(requests).toHaveLength(0); + }); + + it("get-thumb looks up a decimal file ID", async () => { + expect(await getThumbCommand(emptyAccount(), "100", {})).toBe(1); + expect(stderr.text).toBe("File 100 not found\n"); + expect(requests).not.toHaveLength(0); + }); + + it.each(NOT_IDS)("get-thumb refuses %j before any request", async (arg) => { + expect(await getThumbCommand(emptyAccount(), arg, {})).toBe(1); + expect(stderr.text).toBe(`Invalid file ID: ${arg}\n`); + expect(stdout.text).toBe(""); + expect(requests).toHaveLength(0); + }); + + // The fixer reports each file it was given; on this account neither is + // found, which is a failure, so it exits 1. + it("fix-missing-thumbnails looks up each decimal --file ID", async () => { + const ctx = emptyAccount(); + const opts = { file: ["100", "200"], json: true }; + expect(await fixMissingThumbnailsCommand(ctx, opts)).toBe(1); + expect(JSON.parse(stdout.text)).toMatchObject([ + { fileID: 100, reason: "file not found in any collection" }, + { fileID: 200, reason: "file not found in any collection" }, + ]); + expect(requests).not.toHaveLength(0); + }); + + // One bad ID fails the whole command, so the good one before it is not + // fixed either. + it.each(NOT_IDS)( + "fix-missing-thumbnails refuses --file 100 %j before any request", + async (arg) => { + const ctx = emptyAccount(); + const opts = { file: ["100", arg] }; + expect(await fixMissingThumbnailsCommand(ctx, opts)).toBe(1); + expect(stderr.text).toBe(`Invalid file ID: ${arg}\n`); + expect(stdout.text).toBe(""); + expect(requests).toHaveLength(0); + }, + ); +}); From 54133cfa5f9464e5ec3c4613df5af44a5dbabdac Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 14 Oct 2026 03:38:38 +0200 Subject: [PATCH 24/39] Test quak backup end to end against a fake Ente server (closes #186) test/cli/backup-end-to-end.test.ts runs backupCommand through run, as bin/quak.ts does, with the real loadSession reading a session saved after logging in, and every request answered by a fake of the Ente HTTP API serving data encrypted with libsodium. It covers the backup tree of an account with two albums of its own and one shared with it, a second run that downloads nothing, a download that succeeds after two 503s, a 404 recorded in failures.json with exit 1, a refresh answered 401 with exit 3, and --verify putting back an original changed on disk. The fake server and the account it logs in moved from the backup-metadata test into test/ente-server.ts, unchanged, so both tests use them. Model: opus-5-5 --- README.md | 3 +- TODO.md | 11 + test/cli/backup-end-to-end.test.ts | 635 +++++++++++++++++++++++++++++ test/cli/metadata-backup.test.ts | 187 +-------- test/ente-server.ts | 197 +++++++++ 5 files changed, 859 insertions(+), 174 deletions(-) create mode 100644 test/cli/backup-end-to-end.test.ts create mode 100644 test/ente-server.ts diff --git a/README.md b/README.md index 32a0b9d..fe40038 100644 --- a/README.md +++ b/README.md @@ -850,7 +850,8 @@ The library's primary surface is the cache-backed `Library`; the lower-level `Client` sits underneath it and is covered by the Design sections above. The test suite is the canonical, executable documentation — `test/library/` and `test/client/usage.test.ts` walk most operations, `test/cli/backup.test.ts` -walks `lib.backup()`, and `yarn test` verifies them. +walks `lib.backup()`, `test/cli/backup-end-to-end.test.ts` runs `quak backup` +against a fake of the Ente HTTP API, and `yarn test` verifies them. ### Opening a library diff --git a/TODO.md b/TODO.md index bacb24a..87de288 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,17 @@ declares one. # Completed Steps +- 2026-10-14: `test/cli/backup-end-to-end.test.ts` runs `quak backup` as cron + does (issue 186): `backupCommand` and `run`, the real `loadSession` reading a + session saved after logging in, and every request answered by a fake of the + Ente HTTP API serving data encrypted with libsodium. It covers the backup tree + of an account with two albums of its own and one shared with it, a second run + that downloads nothing, a download that succeeds after two 503s, a 404 that + lands in `failures.json` with exit 1, a refresh answered 401 with exit 3, and + `--verify` putting back an original changed on disk. The fake server and the + account it logs in moved from the backup-metadata test into + `test/ente-server.ts`, unchanged. + - 2026-10-14: `files --collection`, `get`, `get-thumb` and `helper fix-missing-thumbnails --file` take an ID only when it is written in decimal digits and is a safe integer (issue 185). Before, `Number()` read diff --git a/test/cli/backup-end-to-end.test.ts b/test/cli/backup-end-to-end.test.ts new file mode 100644 index 0000000..123c00a --- /dev/null +++ b/test/cli/backup-end-to-end.test.ts @@ -0,0 +1,635 @@ +/** + * `quak backup`, end to end. + * + * Each test runs the command as cron runs it: `backupCommand` reads the + * session file that logging in saved, opens the library on the real `Client` + * and backs the whole account up into a directory, and `run` turns the outcome + * into the exit code, as `bin/quak.ts` does. Only the server is a stand-in: + * `buildMetaFetch` from `test/ente-server.ts` answers the Ente HTTP API from + * data this file encrypts with libsodium, as Ente's clients encrypt it. So + * quak's own code fetches each original, decrypts it, checks it against the + * hash its uploader recorded and unpacks a live photo, and quak's own retries + * repeat a failed request. + * + * The account, user 42, has three albums: + * + * Trips its own: beach.jpg + * Family its own: beach.jpg and the live photo IMG_0002.HEIC + * Friend's Wedding shared with it by user 99: toast.mp4 + * + * beach.jpg is one file in two albums. It holds EXIF (a Canon EOS R5), and + * Ente has ML data for it. All three files were taken at noon on 1 March 2024. + */ + +import { + existsSync, + lstatSync, + mkdtempSync, + readdirSync, + readFileSync, + readlinkSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Writable } from "node:stream"; +import { gzipSync } from "node:zlib"; +import sodium from "libsodium-wrappers-sumo"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { + backupCommand, + saveSession, + type CliContext, +} from "../../src/cli-commands.js"; +import { run } from "../../src/cli-run.js"; +import { loadSession } from "../../src/cli-session.js"; +import { Client } from "../../src/client.js"; +import { init, toBase64 } from "../../src/crypto/index.js"; +import { UNATTENDED_RETRY_OPTIONS } from "../../src/retry.js"; +import { + buildAccount, + buildMetaFetch, + encryptSecretbox, + encryptStreamBlob, +} from "../ente-server.js"; +import { HEIC_WITH_EXIF } from "../exif-heic.js"; +import { JPEG_WITH_EXIF } from "../exif-jpeg.js"; +import { blake2b, livePhotoHash, livePhotoZip, VIDEO } from "../live-photo.js"; + +const EMAIL = "backup@example.com"; +const PASSWORD = "backup-password"; +// The fake server logs every account in as user 42. +const USER_ID = 42; +const FRIEND_ID = 99; + +// Noon on 1 March 2024 in the time zone of the machine running the test, the +// one quak dates save paths in, in microseconds, as Ente records times. +const TAKEN = new Date(2024, 2, 1, 12).getTime() * 1000; +// The date folder every original is saved in. +const DAY = "2024/2024-03/2024-03-01"; + +const TOAST = new TextEncoder().encode("a few seconds of the wedding toast"); + +// Ente's ML data for beach.jpg: a face it found, and the photo's CLIP +// embedding. +const ML_DATA = { + face: { faces: [{ faceID: "100_1", score: 0.98 }] }, + clip: { embedding: [0.5, 0.6, 0.7] }, +}; + +// Each file's ID, its owner, its original as uploaded, and the metadata its +// uploader recorded. `fileType` is Ente's number for the kind: 0 an image, 1 a +// video, 2 a live photo, whose original is a ZIP of its image and its video. +// `hash` is the content hash a download must match. +const BEACH = { + id: 100, + ownerID: USER_ID, + original: JPEG_WITH_EXIF, + metadata: { + title: "beach.jpg", + fileType: 0, + hash: blake2b(JPEG_WITH_EXIF), + }, +}; +const TOAST_VIDEO = { + id: 200, + ownerID: FRIEND_ID, + original: TOAST, + metadata: { title: "toast.mp4", fileType: 1, hash: blake2b(TOAST) }, +}; +const LIVE_PHOTO = { + id: 300, + ownerID: USER_ID, + original: livePhotoZip({ + "image.heic": HEIC_WITH_EXIF, + "video.mov": VIDEO, + }), + metadata: { + title: "IMG_0002.HEIC", + fileType: 2, + hash: livePhotoHash(HEIC_WITH_EXIF, VIDEO), + }, +}; + +// The fake Ente server, as the `fetch` quak sends its requests through. +let server: typeof globalThis.fetch; +let root: string; +let sessionDir: string; + +// The fake server, holding the account encrypted as Ente's clients upload it. +const buildServer = async () => { + const account = await buildAccount(PASSWORD); + + // An album as `/collections/v2` lists it, its name encrypted with its own + // key. An album of the account's own carries that key encrypted with the + // master key; an album shared with it carries the key sealed to its public + // key, and no nonce. + const album = (id: number, name: string, ownerID: number) => { + const key = sodium.crypto_secretbox_keygen(); + const encryptedName = encryptSecretbox( + new TextEncoder().encode(name), + key, + ); + let keyFields: { encryptedKey: string; keyDecryptionNonce?: string }; + if (ownerID === USER_ID) { + const encryptedKey = encryptSecretbox(key, account.masterKey); + keyFields = { + encryptedKey: toBase64(encryptedKey.ciphertext), + keyDecryptionNonce: toBase64(encryptedKey.nonce), + }; + } else { + const sealedKey = sodium.crypto_box_seal(key, account.publicKey); + keyFields = { encryptedKey: toBase64(sealedKey) }; + } + const row = { + id, + owner: { id: ownerID }, + ...keyFields, + encryptedName: toBase64(encryptedName.ciphertext), + nameDecryptionNonce: toBase64(encryptedName.nonce), + type: "album", + updationTime: TAKEN, + }; + return { key, row }; + }; + const trips = album(10, "Trips", USER_ID); + const family = album(20, "Family", USER_ID); + const wedding = album(30, "Friend's Wedding", FRIEND_ID); + + // Each file's original is encrypted with the file's own key, as one + // secretstream, which the server serves whole. + const encrypted = (file: typeof BEACH) => { + const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + return { file, key, content: encryptStreamBlob(file.original, key) }; + }; + const beach = encrypted(BEACH); + const toast = encrypted(TOAST_VIDEO); + const livePhoto = encrypted(LIVE_PHOTO); + + // A file as an album's `/collections/v2/diff` lists it: its key encrypted + // with the album's key, its metadata encrypted with its own key, and the + // header that starts the decryption of its original. + const inAlbum = ( + { file, key, content }: ReturnType<typeof encrypted>, + collectionID: number, + albumKey: Uint8Array, + ) => { + const encryptedKey = encryptSecretbox(key, albumKey); + const metadata = encryptStreamBlob( + new TextEncoder().encode( + JSON.stringify({ + ...file.metadata, + creationTime: TAKEN, + modificationTime: TAKEN, + }), + ), + key, + ); + return { + id: file.id, + collectionID, + ownerID: file.ownerID, + encryptedKey: toBase64(encryptedKey.ciphertext), + keyDecryptionNonce: toBase64(encryptedKey.nonce), + metadata: { + encryptedData: toBase64(metadata.ciphertext), + decryptionHeader: toBase64(metadata.header), + }, + file: { decryptionHeader: toBase64(content.header) }, + thumbnail: { + decryptionHeader: toBase64(sodium.randombytes_buf(24)), + }, + updationTime: TAKEN, + }; + }; + + // ML data is gzipped JSON, encrypted with the file's key. + const mlData = encryptStreamBlob( + gzipSync(JSON.stringify(ML_DATA)), + beach.key, + ); + + return buildMetaFetch({ + verifier: account.verifier, + srpAttributes: account.srpAttributes, + keyAttributes: account.keyAttributes, + encryptedToken: account.encryptedToken, + collections: [trips.row, family.row, wedding.row], + filesByCollection: { + 10: [inAlbum(beach, 10, trips.key)], + 20: [ + inAlbum(beach, 20, family.key), + inAlbum(livePhoto, 20, family.key), + ], + 30: [inAlbum(toast, 30, wedding.key)], + }, + encryptedMLData: { + 100: { + encryptedData: toBase64(mlData.ciphertext), + decryptionHeader: toBase64(mlData.header), + }, + }, + fileCiphertexts: { + 100: beach.content.ciphertext, + 200: toast.content.ciphertext, + 300: livePhoto.content.ciphertext, + }, + }); +}; + +beforeAll(async () => { + await init(); + await sodium.ready; + root = mkdtempSync(join(tmpdir(), "quak-backup-end-to-end-")); + server = await buildServer(); + + // Log in once, as `quak login` does: the session file it saves is what + // every backup below reads. + sessionDir = join(root, "session"); + const client = await Client.login({ + email: EMAIL, + password: PASSWORD, + apiOptions: { fetch: server }, + }); + saveSession(sessionDir, client.toJSON()); +}); + +afterAll(() => { + rmSync(root, { recursive: true, force: true }); +}); + +// Run `quak backup <dir>` as `bin/quak.ts` runs it, sending its requests +// through `fetch`, and return its exit code and everything it printed on +// stderr. Its requests are retried as those of `quak backup` are, except that +// a retry does not wait. Its cache is beside `dir`, so each run of one +// directory finds the cache the run before it left, as cron's runs find the +// user's. +const backup = async ( + dir: string, + opts: { verify?: boolean } = {}, + fetch = server, +): Promise<{ code: number; stderr: string }> => { + let printed = ""; + const stderr = new Writable({ + write(chunk: Buffer, _encoding, done) { + printed += chunk.toString(); + done(); + }, + }); + const stdout = new Writable({ + write(_chunk, _encoding, done) { + done(); + }, + }); + // A backup never prompts: any of these ends the run with exit 1. + const prompted = async (): Promise<never> => { + throw new Error("quak backup prompted"); + }; + const ctx: CliContext = { + stdout, + stderr, + sessionDir, + cacheDir: `${dir}-cache`, + loadSession: (path) => + loadSession(path, { + fetch, + retry: { ...UNATTENDED_RETRY_OPTIONS, sleep: async () => {} }, + }), + login: prompted, + prompt: prompted, + promptSecret: prompted, + }; + const code = await new Promise<number>((resolve) => { + void run(backupCommand(ctx, dir, opts), stdout, stderr, resolve); + }); + return { code, stderr: printed }; +}; + +// The ID of the file whose original a request downloads, or undefined for any +// other request. quak downloads an original from +// `https://files.ente.io/?fileID=<id>`. +const downloadOf = (input: RequestInfo | URL): number | undefined => { + const url = new URL(String(input)); + return url.hostname === "files.ente.io" + ? Number(url.searchParams.get("fileID")) + : undefined; +}; + +// The fake server, pushing onto `downloads` the ID of each file whose original +// is downloaded. +const recordingDownloads = + (downloads: number[]): typeof globalThis.fetch => + async (input, init) => { + const fileID = downloadOf(input); + if (fileID !== undefined) downloads.push(fileID); + return server(input, init); + }; + +// Every entry under `dir`, relative to it, sorted, a symlink as +// `name -> target`. +const listTree = (dir: string): string[] => + readdirSync(dir, { recursive: true }) + .sort() + .map((name) => { + const path = join(dir, name); + return lstatSync(path).isSymbolicLink() + ? `${name} -> ${readlinkSync(path)}` + : name; + }); + +const readJSON = (path: string) => JSON.parse(readFileSync(path, "utf-8")); + +describe("the first backup of the account", () => { + let dir: string; + let code: number; + const downloads: number[] = []; + + beforeAll(async () => { + dir = mkdtempSync(join(root, "backup-")); + ({ code } = await backup(dir, {}, recordingDownloads(downloads))); + }); + + it("exits 0, having downloaded each file once", () => { + expect(code).toBe(0); + // beach.jpg is in two albums but is downloaded, and stored, once. + expect(downloads.sort()).toEqual([100, 200, 300]); + }); + + // The tree "Backup layout" in the README describes. Each original is at + // its save path, `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>`, with + // the extension of its title; the live photo is its image and its video, + // each with the extension it had inside the ZIP, and a `.livephoto.json` + // naming the two. Beside each original is its JSON. Each album is a + // folder of links named after the files' titles, with a JSON file beside + // it. No file failed, so there is no `failures.json`, and the run removed + // its lock, `backup.lock`, as it ended. + it("lays the backup out as the README describes", () => { + expect(listTree(dir)).toEqual([ + "2024", + "2024/2024-03", + "2024/2024-03/2024-03-01", + "2024/2024-03/2024-03-01/2024-03-01.100.jpg", + "2024/2024-03/2024-03-01/2024-03-01.100.json", + "2024/2024-03/2024-03-01/2024-03-01.200.json", + "2024/2024-03/2024-03-01/2024-03-01.200.mp4", + "2024/2024-03/2024-03-01/2024-03-01.300.heic", + "2024/2024-03/2024-03-01/2024-03-01.300.json", + "2024/2024-03/2024-03-01/2024-03-01.300.livephoto.json", + "2024/2024-03/2024-03-01/2024-03-01.300.mov", + "account.json", + "collections", + "collections/Family", + "collections/Family.json", + "collections/Family/IMG_0002.heic -> ../../2024/2024-03/2024-03-01/2024-03-01.300.heic", + "collections/Family/IMG_0002.mov -> ../../2024/2024-03/2024-03-01/2024-03-01.300.mov", + "collections/Family/beach.jpg -> ../../2024/2024-03/2024-03-01/2024-03-01.100.jpg", + "collections/Friend's Wedding", + "collections/Friend's Wedding.json", + "collections/Friend's Wedding/toast.mp4 -> ../../2024/2024-03/2024-03-01/2024-03-01.200.mp4", + "collections/Trips", + "collections/Trips.json", + "collections/Trips/beach.jpg -> ../../2024/2024-03/2024-03-01/2024-03-01.100.jpg", + ]); + }); + + it("stores each original decrypted, a live photo as its image and its video", () => { + const day = join(dir, DAY); + expect(readFileSync(join(day, "2024-03-01.100.jpg"))).toEqual( + Buffer.from(JPEG_WITH_EXIF), + ); + expect(readFileSync(join(day, "2024-03-01.200.mp4"))).toEqual( + Buffer.from(TOAST), + ); + expect(readFileSync(join(day, "2024-03-01.300.heic"))).toEqual( + Buffer.from(HEIC_WITH_EXIF), + ); + expect(readFileSync(join(day, "2024-03-01.300.mov"))).toEqual( + Buffer.from(VIDEO), + ); + expect(readJSON(join(day, "2024-03-01.300.livephoto.json"))).toEqual({ + image: "2024-03-01.300.heic", + video: "2024-03-01.300.mov", + }); + }); + + // A file's JSON holds what Ente records for it, decrypted, and what quak + // read from its original: its ML data when Ente has any, and, for an + // image, its EXIF. A video gets no image metadata. + it("writes each file's JSON with its ML data and image metadata", () => { + const day = join(dir, DAY); + + const beach = readJSON(join(day, "2024-03-01.100.json")); + expect(beach).toMatchObject({ + id: 100, + ownerID: USER_ID, + metadata: { + title: "beach.jpg", + fileType: "image", + creationTime: TAKEN, + hash: BEACH.metadata.hash, + }, + updationTime: TAKEN, + }); + expect(beach.mlData).toEqual(ML_DATA); + expect(beach.imageMetadata.exif.Make.description).toBe("Canon"); + expect(beach.imageMetadata.exif.Model.description).toBe("EOS R5"); + + const toast = readJSON(join(day, "2024-03-01.200.json")); + expect(toast).toMatchObject({ + id: 200, + ownerID: FRIEND_ID, + metadata: { title: "toast.mp4", fileType: "video" }, + }); + expect(toast).not.toHaveProperty("mlData"); + expect(toast).not.toHaveProperty("imageMetadata"); + expect(toast).not.toHaveProperty("imageMetadataError"); + + // A live photo's image metadata is read from its image. + const livePhoto = readJSON(join(day, "2024-03-01.300.json")); + expect(livePhoto).toMatchObject({ + id: 300, + ownerID: USER_ID, + metadata: { + title: "IMG_0002.HEIC", + fileType: "livePhoto", + hash: LIVE_PHOTO.metadata.hash, + }, + }); + expect(livePhoto.imageMetadata.exif.Make.description).toBe("Canon"); + }); + + it("writes each album's JSON and the account's", () => { + const albumJSON = (name: string) => + readJSON(join(dir, "collections", `${name}.json`)); + const fileIDs = (album: { files: { id: number }[] }) => + album.files.map((f) => f.id); + + const trips = albumJSON("Trips"); + expect(trips).toMatchObject({ + id: 10, + name: "Trips", + type: "album", + ownerID: USER_ID, + isShared: false, + updationTime: TAKEN, + }); + expect(fileIDs(trips)).toEqual([100]); + + const family = albumJSON("Family"); + expect(family).toMatchObject({ id: 20, isShared: false }); + expect(fileIDs(family)).toEqual([100, 300]); + + const wedding = albumJSON("Friend's Wedding"); + expect(wedding).toMatchObject({ + id: 30, + ownerID: FRIEND_ID, + isShared: true, + }); + expect(fileIDs(wedding)).toEqual([200]); + + expect(readJSON(join(dir, "account.json"))).toEqual({ + email: EMAIL, + userID: USER_ID, + }); + }); +}); + +describe("quak backup", () => { + // An original already at its save path is complete, so a later run, such + // as the next night's from cron, only rewrites the JSON files and the + // album links. + it("downloads nothing on a second run, and exits 0", async () => { + const dir = mkdtempSync(join(root, "backup-")); + expect((await backup(dir)).code).toBe(0); + const firstTree = listTree(dir); + + const downloads: number[] = []; + const { code } = await backup(dir, {}, recordingDownloads(downloads)); + expect(code).toBe(0); + expect(downloads).toEqual([]); + expect(listTree(dir)).toEqual(firstTree); + }); + + // The files CDN is briefly unavailable for toast.mp4: its first two + // downloads answer 503. A 5xx is worth repeating, so quak tries again, + // here without waiting, and the third download succeeds. + it("stores a file whose download fails twice with 503 and then succeeds, and exits 0", async () => { + let attempts = 0; + const fetch: typeof globalThis.fetch = async (input, init) => { + if (downloadOf(input) === 200 && ++attempts <= 2) { + return new Response("unavailable", { status: 503 }); + } + return server(input, init); + }; + const dir = mkdtempSync(join(root, "backup-")); + + const { code } = await backup(dir, {}, fetch); + + expect(code).toBe(0); + expect(attempts).toBe(3); + expect(readFileSync(join(dir, DAY, "2024-03-01.200.mp4"))).toEqual( + Buffer.from(TOAST), + ); + expect(existsSync(join(dir, "failures.json"))).toBe(false); + }); + + // The server answers 404 for toast.mp4 on every try. A 404 is an answer, + // not a hiccup, so quak does not repeat it: the file goes into + // `failures.json` with the kind of failure, how many times it has been + // tried and why, the backup goes on with the other files, and the exit + // code is 1, so the next run tries again. + it("records a file whose download always answers 404 in failures.json, stores the rest, and exits 1", async () => { + let attempts = 0; + const fetch: typeof globalThis.fetch = async (input, init) => { + if (downloadOf(input) === 200) { + attempts++; + return new Response("not found", { status: 404 }); + } + return server(input, init); + }; + const dir = mkdtempSync(join(root, "backup-")); + + const { code, stderr } = await backup(dir, {}, fetch); + + expect(code).toBe(1); + expect(attempts).toBe(1); + const failures = readJSON(join(dir, "failures.json")); + expect(Object.keys(failures.files)).toEqual(["200"]); + expect(failures.files["200"]).toMatchObject({ + fileID: 200, + title: "toast.mp4", + classification: "permanent", + attempts: 1, + }); + expect(failures.files["200"].error).toContain("404"); + expect(stderr).toContain("[Friend's Wedding] toast.mp4 (id 200): "); + + // The rest of the account is backed up; the video is not, and its + // album folder has no link to it. + expect(readFileSync(join(dir, DAY, "2024-03-01.100.jpg"))).toEqual( + Buffer.from(JPEG_WITH_EXIF), + ); + expect(readFileSync(join(dir, DAY, "2024-03-01.300.heic"))).toEqual( + Buffer.from(HEIC_WITH_EXIF), + ); + expect(existsSync(join(dir, DAY, "2024-03-01.200.mp4"))).toBe(false); + expect( + readdirSync(join(dir, "collections", "Friend's Wedding")), + ).toEqual([]); + }); + + // The server no longer accepts the saved session's token, as after + // `quak logout` elsewhere, and answers every request with 401. The run + // stops at the refresh it starts with, says to log in again, and exits 3. + // It took its lock first, so every request was sent while the lock was + // there, and it removed the lock as it ended: the directory is left as it + // was, empty. + it("exits 3 when the refresh is answered with 401, writing nothing but the lock", async () => { + const dir = mkdtempSync(join(root, "backup-")); + const lockAtEachRequest: boolean[] = []; + const fetch: typeof globalThis.fetch = async () => { + lockAtEachRequest.push(existsSync(join(dir, "backup.lock"))); + return new Response(null, { status: 401 }); + }; + + const { code, stderr } = await backup(dir, {}, fetch); + + expect(code).toBe(3); + expect(stderr).toBe( + "Starting backup...\n" + + "Refreshing library...\n" + + `quak: the saved session is no longer valid; run "quak login"\n`, + ); + expect(lockAtEachRequest).not.toHaveLength(0); + expect(lockAtEachRequest).not.toContain(false); + expect(readdirSync(dir)).toEqual([]); + }); + + // Something changed beach.jpg on disk after it was backed up. A run with + // --verify hashes every stored original, finds that one no longer matches + // the hash Ente recorded, says so, and downloads it again; it put back the + // right bytes, so the exit code is 0. + it("--verify puts back an original that changed on disk, and exits 0", async () => { + const dir = mkdtempSync(join(root, "backup-")); + expect((await backup(dir)).code).toBe(0); + const beach = join(dir, DAY, "2024-03-01.100.jpg"); + writeFileSync(beach, "not the photo any more"); + + const downloads: number[] = []; + const { code, stderr } = await backup( + dir, + { verify: true }, + recordingDownloads(downloads), + ); + + expect(code).toBe(0); + expect(downloads).toEqual([100]); + expect(readFileSync(beach)).toEqual(Buffer.from(JPEG_WITH_EXIF)); + expect(stderr).toContain( + "MISMATCH original beach.jpg (100): its bytes do not match its content hash", + ); + expect(stderr).toContain(" Verified: 2\n Mismatched: 1\n"); + }); +}); diff --git a/test/cli/metadata-backup.test.ts b/test/cli/metadata-backup.test.ts index 6d597e5..5bff19c 100644 --- a/test/cli/metadata-backup.test.ts +++ b/test/cli/metadata-backup.test.ts @@ -37,15 +37,8 @@ import { import { join } from "node:path"; import { tmpdir } from "node:os"; import sodium from "libsodium-wrappers-sumo"; -import { SRP, SrpServer } from "fast-srp-hap"; import { beforeAll, afterAll, describe, expect, it, vi } from "vitest"; -import { - init, - toBase64, - deriveKEK, - deriveLoginSubkey, - encryptBlob, -} from "../../src/crypto/index.js"; +import { init, toBase64, encryptBlob } from "../../src/crypto/index.js"; import * as jpegJs from "jpeg-js"; import { Client } from "../../src/client.js"; import { Library } from "../../src/library/index.js"; @@ -54,7 +47,13 @@ import { type MetadataBackupOptions, } from "../../src/metadata-backup.js"; import { backupMetadataCommand } from "../../src/cli-commands.js"; -import type { KeyAttributes } from "../../src/auth/types.js"; +import { + buildAccount, + buildMetaFetch, + encryptSecretbox, + encryptStreamBlob, + type MetaMockState, +} from "../ente-server.js"; // One file per ML data request, so the two files of the mock account are // fetched in two requests and one of them can fail on its own. @@ -65,80 +64,13 @@ vi.mock("../../src/mldata-fetch.js", async (importOriginal) => ({ const TEST_EMAIL = "metabackup@example.com"; const TEST_PASSWORD = "metapass"; -const TEST_OPS = 2; -const TEST_MEM = 64 * 1024 * 1024; - -interface MetaMockState { - verifier: Buffer; - srpAttributes: Record<string, unknown>; - keyAttributes: KeyAttributes; - encryptedToken: string; - collections: Record<string, unknown>[]; - filesByCollection: Record<number, Record<string, unknown>[]>; - // For ML data and EXIF tests - encryptedMLData: Record< - number, - { encryptedData: string; decryptionHeader: string } - >; - fileCiphertexts: Record<number, Uint8Array>; -} let mock: MetaMockState; let testDir: string; -const encryptSecretbox = (plaintext: Uint8Array, key: Uint8Array) => { - const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); - const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key); - return { ciphertext, nonce }; -}; - -const encryptStreamBlob = (plaintext: Uint8Array, key: Uint8Array) => { - const push = sodium.crypto_secretstream_xchacha20poly1305_init_push(key); - const ciphertext = sodium.crypto_secretstream_xchacha20poly1305_push( - push.state, - plaintext, - null, - sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, - ); - return { ciphertext, header: push.header }; -}; - const buildMetaMock = async (): Promise<MetaMockState> => { - const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES); - const kek = await deriveKEK(TEST_PASSWORD, kekSalt, TEST_OPS, TEST_MEM); - const loginSubKeyBytes = deriveLoginSubkey(kek); - const srpUserID = "meta-srp"; - const srpSalt = sodium.randombytes_buf(16); - const verifier = SRP.computeVerifier( - SRP.params["4096"], - Buffer.from(srpSalt), - Buffer.from(srpUserID), - Buffer.from(loginSubKeyBytes), - ); - - const masterKey = sodium.randombytes_buf(32); - const { ciphertext: encMK, nonce: mkNonce } = encryptSecretbox( - masterKey, - kek, - ); - const kp = sodium.crypto_box_keypair(); - const { ciphertext: encSK, nonce: skNonce } = encryptSecretbox( - kp.privateKey, - masterKey, - ); - const tokenBytes = sodium.randombytes_buf(32); - const encToken = sodium.crypto_box_seal(tokenBytes, kp.publicKey); - - const keyAttributes: KeyAttributes = { - kekSalt: toBase64(kekSalt), - encryptedKey: toBase64(encMK), - keyDecryptionNonce: toBase64(mkNonce), - publicKey: toBase64(kp.publicKey), - encryptedSecretKey: toBase64(encSK), - secretKeyDecryptionNonce: toBase64(skNonce), - memLimit: TEST_MEM, - opsLimit: TEST_OPS, - }; + const account = await buildAccount(TEST_PASSWORD); + const masterKey = account.masterKey; // Collection 1: "Vacation" with collection-level pubMagicMetadata const ck1 = sodium.crypto_secretbox_keygen(); @@ -332,17 +264,10 @@ const buildMetaMock = async (): Promise<MetaMockState> => { rawFile1.file.decryptionHeader = toBase64(filePush1.header); return { - verifier, - srpAttributes: { - srpUserID, - srpSalt: toBase64(srpSalt), - memLimit: TEST_MEM, - opsLimit: TEST_OPS, - kekSalt: toBase64(kekSalt), - isEmailMFAEnabled: false, - }, - keyAttributes, - encryptedToken: toBase64(encToken), + verifier: account.verifier, + srpAttributes: account.srpAttributes, + keyAttributes: account.keyAttributes, + encryptedToken: account.encryptedToken, collections: [rawColl1, rawColl2], filesByCollection: { 10: [rawFile1], 20: [rawFile2] }, encryptedMLData: { @@ -355,90 +280,6 @@ const buildMetaMock = async (): Promise<MetaMockState> => { }; }; -// `failMLDataFor`: answer 500 to every ML data request that asks for this file. -const buildMetaFetch = (m: MetaMockState, failMLDataFor?: number) => { - let srpServer: SrpServer; - return (async ( - input: RequestInfo | URL, - init?: RequestInit, - ): Promise<Response> => { - const url = - typeof input === "string" - ? input - : input instanceof URL - ? input.href - : input.url; - const path = new URL(url).pathname; - const json = (body: unknown) => - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); - - if (path === "/users/srp/attributes") - return json({ attributes: m.srpAttributes }); - if (path === "/users/srp/create-session") { - const body = JSON.parse(init?.body as string); - const serverKey = await SRP.genKey(); - srpServer = new SrpServer( - SRP.params["4096"], - m.verifier, - serverKey, - ); - const B = srpServer.computeB(); - srpServer.setA(Buffer.from(body.srpA, "base64")); - return json({ sessionID: "s1", srpB: B.toString("base64") }); - } - if (path === "/users/srp/verify-session") { - const body = JSON.parse(init?.body as string); - srpServer.checkM1(Buffer.from(body.srpM1, "base64")); - return json({ - srpM2: srpServer.computeM2().toString("base64"), - id: 42, - keyAttributes: m.keyAttributes, - encryptedToken: m.encryptedToken, - }); - } - if (path === "/collections/v2") - return json({ collections: m.collections }); - if (path === "/collections/v2/diff") { - const collID = Number( - new URL(url).searchParams.get("collectionID"), - ); - return json({ - diff: m.filesByCollection[collID] ?? [], - hasMore: false, - }); - } - if (path === "/files/data/fetch") { - const body = JSON.parse(init?.body as string); - if ((body.fileIDs as number[]).includes(failMLDataFor!)) - return new Response("server error", { status: 500 }); - const data = (body.fileIDs as number[]) - .filter((id: number) => m.encryptedMLData[id]) - .map((id: number) => ({ - fileID: id, - ...m.encryptedMLData[id], - updatedAt: 1700000000000000, - })); - return json({ data }); - } - if ( - url.includes("files.ente.io") || - path.startsWith("/files/download/") - ) { - const parsed = new URL(url); - const fileID = Number( - parsed.searchParams.get("fileID") ?? path.split("/").pop(), - ); - const ct = m.fileCiphertexts[fileID]; - if (ct) return new Response(ct, { status: 200 }); - return new Response("not found", { status: 404 }); - } - return new Response("not found", { status: 404 }); - }) as typeof globalThis.fetch; -}; - beforeAll(async () => { await init(); await sodium.ready; diff --git a/test/ente-server.ts b/test/ente-server.ts new file mode 100644 index 0000000..64b95db --- /dev/null +++ b/test/ente-server.ts @@ -0,0 +1,197 @@ +/** + * A fake of the Ente HTTP API, for tests that run quak's real `Client` against + * a server. + * + * `buildMetaFetch` is a `fetch` that answers the requests quak makes, from a + * `MetaMockState` the test builds: the SRP login, `/collections/v2`, each + * collection's `/collections/v2/diff`, the ML data of `/files/data/fetch`, and + * each file's content from the files CDN, a 404 for a file it holds none for. + * The test encrypts everything in that state with libsodium, as Ente's clients + * do, so quak decrypts it with its own code. Nothing hits the network. + * + * `buildAccount` builds what the server holds to log an account in, and the + * keys a test encrypts that account's collections with. Call `init()` from + * `src/crypto` and await `sodium.ready` before using either. + */ + +import sodium from "libsodium-wrappers-sumo"; +import { SRP, SrpServer } from "fast-srp-hap"; +import { toBase64, deriveKEK, deriveLoginSubkey } from "../src/crypto/index.js"; +import type { KeyAttributes } from "../src/auth/types.js"; + +const TEST_OPS = 2; +const TEST_MEM = 64 * 1024 * 1024; + +export interface MetaMockState { + verifier: Buffer; + srpAttributes: Record<string, unknown>; + keyAttributes: KeyAttributes; + encryptedToken: string; + collections: Record<string, unknown>[]; + filesByCollection: Record<number, Record<string, unknown>[]>; + // For ML data and EXIF tests + encryptedMLData: Record< + number, + { encryptedData: string; decryptionHeader: string } + >; + fileCiphertexts: Record<number, Uint8Array>; +} + +export const encryptSecretbox = (plaintext: Uint8Array, key: Uint8Array) => { + const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key); + return { ciphertext, nonce }; +}; + +export const encryptStreamBlob = (plaintext: Uint8Array, key: Uint8Array) => { + const push = sodium.crypto_secretstream_xchacha20poly1305_init_push(key); + const ciphertext = sodium.crypto_secretstream_xchacha20poly1305_push( + push.state, + plaintext, + null, + sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, + ); + return { ciphertext, header: push.header }; +}; + +// An account whose password is `password`. The first four fields are those of +// `MetaMockState`; with the master key the test encrypts the keys of the +// account's own collections, and to the public key it seals the keys of the +// collections other accounts share with it. +export const buildAccount = async (password: string) => { + const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES); + const kek = await deriveKEK(password, kekSalt, TEST_OPS, TEST_MEM); + const loginSubKeyBytes = deriveLoginSubkey(kek); + const srpUserID = "meta-srp"; + const srpSalt = sodium.randombytes_buf(16); + const verifier = SRP.computeVerifier( + SRP.params["4096"], + Buffer.from(srpSalt), + Buffer.from(srpUserID), + Buffer.from(loginSubKeyBytes), + ); + + const masterKey = sodium.randombytes_buf(32); + const { ciphertext: encMK, nonce: mkNonce } = encryptSecretbox( + masterKey, + kek, + ); + const kp = sodium.crypto_box_keypair(); + const { ciphertext: encSK, nonce: skNonce } = encryptSecretbox( + kp.privateKey, + masterKey, + ); + const tokenBytes = sodium.randombytes_buf(32); + const encToken = sodium.crypto_box_seal(tokenBytes, kp.publicKey); + + const keyAttributes: KeyAttributes = { + kekSalt: toBase64(kekSalt), + encryptedKey: toBase64(encMK), + keyDecryptionNonce: toBase64(mkNonce), + publicKey: toBase64(kp.publicKey), + encryptedSecretKey: toBase64(encSK), + secretKeyDecryptionNonce: toBase64(skNonce), + memLimit: TEST_MEM, + opsLimit: TEST_OPS, + }; + + return { + verifier, + srpAttributes: { + srpUserID, + srpSalt: toBase64(srpSalt), + memLimit: TEST_MEM, + opsLimit: TEST_OPS, + kekSalt: toBase64(kekSalt), + isEmailMFAEnabled: false, + }, + keyAttributes, + encryptedToken: toBase64(encToken), + masterKey, + publicKey: kp.publicKey, + }; +}; + +// `failMLDataFor`: answer 500 to every ML data request that asks for this file. +export const buildMetaFetch = (m: MetaMockState, failMLDataFor?: number) => { + let srpServer: SrpServer; + return (async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise<Response> => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const path = new URL(url).pathname; + const json = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + + if (path === "/users/srp/attributes") + return json({ attributes: m.srpAttributes }); + if (path === "/users/srp/create-session") { + const body = JSON.parse(init?.body as string); + const serverKey = await SRP.genKey(); + srpServer = new SrpServer( + SRP.params["4096"], + m.verifier, + serverKey, + ); + const B = srpServer.computeB(); + srpServer.setA(Buffer.from(body.srpA, "base64")); + return json({ sessionID: "s1", srpB: B.toString("base64") }); + } + if (path === "/users/srp/verify-session") { + const body = JSON.parse(init?.body as string); + srpServer.checkM1(Buffer.from(body.srpM1, "base64")); + return json({ + srpM2: srpServer.computeM2().toString("base64"), + id: 42, + keyAttributes: m.keyAttributes, + encryptedToken: m.encryptedToken, + }); + } + if (path === "/collections/v2") + return json({ collections: m.collections }); + if (path === "/collections/v2/diff") { + const collID = Number( + new URL(url).searchParams.get("collectionID"), + ); + return json({ + diff: m.filesByCollection[collID] ?? [], + hasMore: false, + }); + } + if (path === "/files/data/fetch") { + const body = JSON.parse(init?.body as string); + if ((body.fileIDs as number[]).includes(failMLDataFor!)) + return new Response("server error", { status: 500 }); + const data = (body.fileIDs as number[]) + .filter((id: number) => m.encryptedMLData[id]) + .map((id: number) => ({ + fileID: id, + ...m.encryptedMLData[id], + updatedAt: 1700000000000000, + })); + return json({ data }); + } + if ( + url.includes("files.ente.io") || + path.startsWith("/files/download/") + ) { + const parsed = new URL(url); + const fileID = Number( + parsed.searchParams.get("fileID") ?? path.split("/").pop(), + ); + const ct = m.fileCiphertexts[fileID]; + if (ct) return new Response(ct, { status: 200 }); + return new Response("not found", { status: 404 }); + } + return new Response("not found", { status: 404 }); + }) as typeof globalThis.fetch; +}; From 288825b11ae4b14453e130740d0ba250a07e3952 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 14 Oct 2026 04:10:14 +0200 Subject: [PATCH 25/39] quak backup no longer refreshes its library on a timer (closes #183) The backup opened its library with the default 3-second refresh interval, so for its whole run it asked the server for changes every few seconds, and each refresh started another ML data pass. It now opens it with refreshIntervalSeconds: Infinity. The library takes Infinity as no background timer, since setTimeout would turn it into 1 ms. A refresh interval of 3600, as the read commands use, would still refresh once an hour in a backup that runs for hours. Model: opus-5-5 --- README.md | 5 +- TODO.md | 7 ++ src/cli-commands.ts | 3 + src/library/index.ts | 6 +- test/cli/commands.test.ts | 143 +++++++++++++++++++++++++++++++++++++- 5 files changed, 160 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fe40038..5654819 100644 --- a/README.md +++ b/README.md @@ -788,7 +788,8 @@ subsequent runs, existing originals are skipped. If a download fails, the error is logged and the backup continues with the next file. The exit code is non-zero if any files failed. `quak backup` opens its library with the thumbnail and originals precache off, so the only file content it fetches is the originals the -backup stores. +backup stores. It also opens it with no background timer, so it asks the server +for changes only as it starts, not again while it downloads. With `--verify`, or `lib.backup({ verify: true })`, a run also hashes each original already at its save path the way a download is checked (see "On-disk @@ -869,7 +870,7 @@ background, so an unreachable server does not block opening. | `client` | required | the account client (a `Client`, or any `LibraryClient`) | | `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 | -| `refreshIntervalSeconds` | `3` | background refresh cadence | +| `refreshIntervalSeconds` | `3` | background refresh cadence; `Infinity` for no background timer | | `precacheThumbnails` | `true` | prefetch every thumbnail, newest first | | `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals | | `precacheOriginalsDays` | `7` | length in days of that latest window | diff --git a/TODO.md b/TODO.md index 87de288..25a6bfb 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,13 @@ declares one. # Completed Steps +- 2026-10-14: `quak backup` no longer asks the server for changes every 3 + seconds while it runs (issue 183). It opens its library with + `refreshIntervalSeconds: Infinity`, which the library now takes as no + background timer, so the backup refreshes only as it starts and no timed + refresh starts another ML data pass during the run. A test holds a backup's + download open while two hours pass and counts its `/collections/v2` requests. + - 2026-10-14: `test/cli/backup-end-to-end.test.ts` runs `quak backup` as cron does (issue 186): `backupCommand` and `run`, the real `loadSession` reading a session saved after logging in, and every request answered by a fake of the diff --git a/src/cli-commands.ts b/src/cli-commands.ts index 5f8b0b6..154c180 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -427,10 +427,13 @@ export const backupCommand = async ( try { // The precache is off: the backup fetches what it needs, and must not // also fill the cache with every thumbnail and the recent originals. + // No refresh starts on a timer: the backup lists its files once, from + // the refresh it waits for, and can run for hours. const lib = await Library.open({ client, downloadDirectory: dir, cacheDirectory: ctx.cacheDir, + refreshIntervalSeconds: Infinity, precacheThumbnails: false, precacheOriginals: false, }); diff --git a/src/library/index.ts b/src/library/index.ts index 489dd88..080020b 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -179,6 +179,9 @@ export interface LibraryOptions { // directory at open. The content cache treats an original already stored // at its save path as present. downloadDirectory?: string; + // How long after one refresh ends the background timer starts the next. + // `Infinity` starts none: the library then refreshes only as it opens and + // when `fresh()` or `backup()` asks. refreshIntervalSeconds?: number; onProgress?: RefreshProgressCallback; // The bounded request pools (issue #45), shared by the ML-data fetch (the @@ -627,7 +630,8 @@ export class Library { } private scheduleNext(): void { - if (this.closed) return; + // `setTimeout` would turn an infinite delay into 1 ms. + if (this.closed || this.intervalMs === Infinity) return; this.timer = setTimeout(() => { void this.runRefresh().then(() => this.scheduleNext()); }, this.intervalMs); diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index 09189a5..241e259 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -25,6 +25,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { PassThrough } from "node:stream"; import * as jpegJs from "jpeg-js"; +import sodium from "libsodium-wrappers-sumo"; import { describe, it, @@ -55,7 +56,7 @@ import { loadSession } from "../../src/cli-session.js"; import type { Client, ClientSnapshot, LoginOptions } from "../../src/client.js"; import { savePath, type ContentSource } from "../../src/library/content.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; -import { init, toBase64 } from "../../src/crypto/index.js"; +import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js"; import { defaultCacheDirectory } from "../../src/library/index.js"; import { HEIC_WITH_EXIF } from "../exif-heic.js"; import { @@ -903,6 +904,146 @@ describe("backup", () => { // Opening the library would have made its cache directory. expect(existsSync(join(root, "cache"))).toBe(false); }); + + // A backup runs for as long as its downloads take, hours on a large + // account. It lists its files from the refresh it waits for as it starts, + // so it must not ask the server for changes again on a timer while it + // runs: each refresh is a request, and each also starts a pass that asks + // again for the ML data of every file Ente holds none for. + // + // A saved session is read back by `loadSession`, its `fetch` a fake of the + // Ente HTTP API serving one album with one photo, encrypted as the server + // serves them. The fake holds the photo's download open while the clock + // moves on by two hours, then serves it. Only `setTimeout` and + // `clearTimeout` are faked: the library's refresh timer runs on them. + it("asks the server for changes only as it starts, however long it runs", async () => { + const masterKey = new Uint8Array(32).fill(1); + const albumKey = new Uint8Array(32).fill(2); + const photoKey = new Uint8Array(32).fill(3); + saveSession(join(root, "session"), { + email: "cli@example.com", + userID: USER_ID, + token: "token", + masterKey: toBase64(masterKey), + secretKey: toBase64(new Uint8Array(32)), + publicKey: toBase64(new Uint8Array(32)), + }); + // A secretbox as the server sends one: ciphertext and nonce, base64. + const box = (plaintext: Uint8Array, key: Uint8Array) => { + const nonce = sodium.randombytes_buf( + sodium.crypto_secretbox_NONCEBYTES, + ); + return { + ciphertext: toBase64( + sodium.crypto_secretbox_easy(plaintext, nonce, key), + ), + nonce: toBase64(nonce), + }; + }; + const albumKeyBox = box(albumKey, masterKey); + const albumName = box(new TextEncoder().encode("Vacation"), albumKey); + const photoKeyBox = box(photoKey, albumKey); + const metadata = encryptBlob( + new TextEncoder().encode( + JSON.stringify({ + title: "beach.jpg", + fileType: 0, + creationTime: 1_700_000_000_000_000, + modificationTime: 1_700_000_000_000_000, + }), + ), + photoKey, + ); + const content = encryptBlob( + new TextEncoder().encode("the photo's bytes"), + photoKey, + ); + const album = { + id: 1, + owner: { id: USER_ID }, + encryptedKey: albumKeyBox.ciphertext, + keyDecryptionNonce: albumKeyBox.nonce, + encryptedName: albumName.ciphertext, + nameDecryptionNonce: albumName.nonce, + type: "album", + updationTime: 1, + }; + const photo = { + id: 100, + collectionID: 1, + ownerID: USER_ID, + encryptedKey: photoKeyBox.ciphertext, + keyDecryptionNonce: photoKeyBox.nonce, + metadata: { + encryptedData: toBase64(metadata.ciphertext), + decryptionHeader: toBase64(metadata.header), + }, + file: { decryptionHeader: toBase64(content.header) }, + // The backup fetches no thumbnail. + thumbnail: { decryptionHeader: "" }, + updationTime: 1, + }; + + // Each refresh starts with `collectionsSince`, which sends one + // `/collections/v2` request. + let refreshes = 0; + let downloadRequested!: () => void; + const requested = new Promise<void>((resolve) => { + downloadRequested = resolve; + }); + let releaseDownload!: () => void; + const released = new Promise<void>((resolve) => { + releaseDownload = resolve; + }); + const json = (body: unknown): Response => + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }); + const fetch = async (url: string | URL): Promise<Response> => { + const { hostname, pathname } = new URL(String(url)); + if (hostname === "files.ente.io") { + downloadRequested(); + await released; + return new Response(content.ciphertext); + } + if (pathname === "/collections/v2") { + refreshes++; + return json({ collections: [album] }); + } + if (pathname === "/collections/v2/diff") { + return json({ diff: [photo], hasMore: false }); + } + // Ente holds no ML data for the photo. + if (pathname === "/files/data/fetch") return json({ data: [] }); + return new Response("not found", { status: 404 }); + }; + const ctx = { + ...context(), + // The download's idle deadline is set past the two hours, or quak + // would give up the held download as stalled. + loadSession: (path: string) => + loadSession(path, { + fetch: fetch as typeof globalThis.fetch, + downloadTimeoutMs: 3 * 3_600_000, + }), + }; + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const backup = backupCommand(ctx, join(root, "backup"), {}); + await requested; + vi.advanceTimersByTime(2 * 3_600_000); + releaseDownload(); + expect(await backup).toBe(0); + } finally { + vi.useRealTimers(); + } + + // Two refreshes, both before the download: the library opens onto the + // empty cache with one, and the backup waits for the other before it + // lists its files. + expect(refreshes).toBe(2); + }); }); describe("helper list-missing-thumbnails", () => { From bb3b5cd2da5e3c1f85cfbef130f22b22e56094a7 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 14 Oct 2026 07:08:57 +0200 Subject: [PATCH 26/39] One album or file quak cannot decrypt no longer stops a refresh or a backup (closes #181) A file or album whose key, metadata or name does not decrypt used to throw out of the refresh, so quak backup stopped before storing anything, every run. Client.collectionsSince and filesSince now return such a row in undecryptable with the reason, the refresh leaves it out, and the store keeps the list in metadata.json until a later refresh decrypts or deletes the record. lib.backup() records each such file in failures.json unless a copy in another album decrypted, in which case it stores the original from that copy. Each such album is listed in BackupResult.undecryptableAlbums and printed as one FAILED album line. quak backup exits 1 while either remains. An album or file that no longer decrypts loses its album folder, JSON and links; its original stays. Model: opus-5-5 --- README.md | 62 ++- TODO.md | 13 + src/backup.ts | 79 +++- src/cli-commands.ts | 4 +- src/client.ts | 79 ++-- src/library/index.ts | 27 +- src/library/store.ts | 55 ++- test/cli/backup-undecryptable.test.ts | 554 ++++++++++++++++++++++++++ test/cli/backup.test.ts | 1 + test/client/enumeration.test.ts | 136 ++++++- test/library/store.test.ts | 23 ++ 11 files changed, 956 insertions(+), 77 deletions(-) create mode 100644 test/cli/backup-undecryptable.test.ts diff --git a/README.md b/README.md index 5654819..0a7e7a3 100644 --- a/README.md +++ b/README.md @@ -127,19 +127,21 @@ matters. ### Exit codes -| Code | Meaning | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | The backup is complete: every original is at its save path, and no file failed. | -| `1` | The run finished with files in `failures.json`, or it stopped on another error, printed as one line `quak: <message>`. The next run tries again. | -| `2` | Another backup of the same directory is running. This run sent no request and changed nothing. | -| `3` | There is no usable session: none is saved, the saved one is corrupt, or the server no longer accepts it. Run `quak login` as the user the cron job runs as. | +| Code | Meaning | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | The backup is complete: every original is at its save path, and no file failed. | +| `1` | The run finished with files in `failures.json` or an album it could not decrypt, or it stopped on another error, printed as one line `quak: <message>`. The next run tries again. | +| `2` | Another backup of the same directory is running. This run sent no request and changed nothing. | +| `3` | There is no usable session: none is saved, the saved one is corrupt, or the server no longer accepts it. Run `quak login` as the user the cron job runs as. | 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 original that a -`--verify` run could not read, or put back with bytes that still do not match, -stays at its save path. Only a later `--verify` run checks it again: a run -without `--verify` leaves it as it is and takes the file out of `failures.json`, -so that run can exit 0. +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 +the exit code at `1` until the server sends a copy that decrypts, or deletes it +(see "Backup layout"). An original that a `--verify` run could not read, or put +back with bytes that still do not match, stays at its save path. Only a later +`--verify` run checks it again: a run without `--verify` leaves it as it is and +takes the file out of `failures.json`, so that run can exit 0. ## Examples @@ -623,8 +625,10 @@ library. The read commands — `collections`, `files`, `get`, `get-thumb`, answer, so they report current account state rather than whatever the cache last held. If that round-trip fails, the command prints the error on one line and exits 1, or 3 when the server no longer accepts the saved session (see "Session -handling"). `--cache-dir` overrides where the cache lives; without it each -account gets its own directory under the per-user cache path. +handling"). An album or file quak cannot decrypt does not fail it; the command +answers from what did decrypt (see "Default reads vs. fresh reads"). +`--cache-dir` overrides where the cache lives; without it each account gets its +own directory under the per-user cache path. An ID given to `files --collection`, `get`, `get-thumb` or `helper fix-missing-thumbnails --file` is written in decimal digits, no larger @@ -741,6 +745,19 @@ succeeds, or once it is no longer in the library or in the backup's scope. The library's `lib.backup({ includeThumbnails: true })` also writes `thumbnails/<fileID>.jpg` beside `collections/`; `quak backup` does not. +A file in the backup's scope that the library could not decrypt (see "Default +reads vs. fresh reads") goes into `failures.json` with an error that starts +`could not be decrypted:` and gives the reason. If a copy of it in another album +decrypted, it does not: its original is stored from that copy, and the album +where it does not decrypt neither links nor lists it. An album the library could +not decrypt has no name quak can read and no files it can list. A run without +`--json` prints one line for it, +`FAILED album <id>: could not be decrypted: <reason>`, and `--json` and the +`BackupResult` list its ID in `undecryptableAlbums`. Such a file in +`failures.json`, or such an album, keeps the exit code at 1, run after run, +until the server sends a copy that decrypts or deletes it; the next run then +drops it. + `backup.lock` keeps two backups of the same directory from running at once, such as a cron run that starts while the previous one is still going. A backup creates `<dir>` if it is missing and takes the lock before its refresh, and @@ -777,9 +794,12 @@ both, each named after the title with that file's extension (`IMG_0001.heic` and Each run removes the symlinks into the date folders that no longer belong in their collection's directory, and the directories (and JSON) of collections that -were deleted or renamed. Nothing else in `collections/` is touched: a file or a -symlink you put there stays, and a directory that still holds one after its -symlinks are removed stays too, with its JSON. +were deleted or renamed. An album quak can no longer decrypt loses its directory +and JSON the same way, and a file whose copy in an album quak can no longer +decrypt loses its symlink there and its entry in that album's JSON; the +originals stay at their save paths. Nothing else in `collections/` is touched: a +file or a symlink you put there stays, and a directory that still holds one +after its symlinks are removed stays too, with its JSON. Each file is downloaded exactly once regardless of how many collections it appears in, and written once: straight to its save path, with no copy left in @@ -915,6 +935,16 @@ rejects the caller (default reads stay silent and keep serving the last good copy). The CLI's read commands use fresh reads (issue https://git.eeqj.de/sneak/quak/issues/75). +An album or file in the server's answer that quak cannot decrypt, or whose +metadata is not JSON, does not fail the refresh. The refresh leaves it out, an +album with all its files, and drops any older copy the cache held, so reads +answer from what did decrypt. The library keeps a list of what it left out, with +the reason, in `metadata.json`: the next refresh asks the server only for what +changed, so it would not see the record again. A record stays on that list until +a later refresh gets a copy that decrypts or learns that the server deleted it. +`lib.backup()` reports each such album, and each such file in its scope that has +no copy that decrypted (see "Backup layout"). + ### Read surface - `lib.albums.list()` → `Album[]`, newest-updated first. diff --git a/TODO.md b/TODO.md index 25a6bfb..e170323 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,19 @@ declares one. # Completed Steps +- 2026-10-14: An album or file quak cannot decrypt, or whose metadata is not + JSON, no longer fails the refresh, so one bad record no longer stops + `quak backup` from storing anything (issue 181). The refresh leaves it out, an + album with its files, and the library keeps it with the reason in + `metadata.json` until the server sends a copy that decrypts or deletes it. + `quak backup` stores every other file, records each such file in + `failures.json` with an error starting `could not be decrypted:`, names each + such album's ID in a printed line, or with `--json` in `undecryptableAlbums`, + and exits 1 while any remain. A file whose copy in another album decrypts is + not recorded: its original is stored from that copy, and the album where it + does not decrypt neither links nor lists it. The read commands answer from + what decrypted. + - 2026-10-14: `quak backup` no longer asks the server for changes every 3 seconds while it runs (issue 183). It opens its library with `refreshIntervalSeconds: Infinity`, which the library now takes as no diff --git a/src/backup.ts b/src/backup.ts index 184d466..5deae14 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -47,6 +47,9 @@ // download, a failed symlink, or ML data missing because the ML data fetch // failed is caught, recorded in `failures.json` with a classification, a // running attempt count, and the last-tried time, and the run continues. +// A file the library could not decrypt is recorded there too, unless a copy of +// it in another album decrypted; an album it could not decrypt is logged by ID +// and listed in `result.undecryptableAlbums`, as its name is not known. // `result.failed` — and thus the CLI's exit code — stays non-zero while any // failure remains unresolved and clears once every one succeeds. Each run // reconciles the ledger against the files it attempted, so an entry for a file @@ -86,6 +89,7 @@ import { storedAtSavePath, } from "./library/content.js"; import { representative } from "./library/records.js"; +import type { Undecryptable } from "./library/store.js"; import { extractImageMetadata } from "./metadata-backup.js"; import type { MLData } from "./mldata-fetch.js"; import type { Collection, EnteFile, FileMetadata } from "./model/types.js"; @@ -141,6 +145,9 @@ export interface BackupResult { failed: number; // This run's per-file errors, in encounter order. errors: BackupError[]; + // The IDs of the albums the library could not decrypt, whose files it + // could not list; the CLI exits non-zero while there are any. + undecryptableAlbums: number[]; } // The slice of the library that backup drives. `Library` implements it; a test @@ -151,6 +158,9 @@ export interface BackupLibrary { refresh(): Promise<void>; listCollections(): Collection[]; listFiles(collectionID: number): EnteFile[]; + // The albums, and files in albums, that the library could not decrypt, + // which `listCollections` and `listFiles` leave out. + undecryptable(): Undecryptable[]; // Get an original's bytes onto disk through the content cache/pools, // returning where they landed: `destination` when they were fetched now, // otherwise wherever they already were (the cache, or the library's save @@ -574,33 +584,56 @@ const runLockedBackup = async ( let unchecked = 0; const recordFailure = ( - file: EnteFile, + fileID: number, + title: string, collection: string, err: unknown, ): void => { // Count at most one attempt per file per run: a file whose original // and thumbnail both fail this run must not double its attempt count // or appear twice in errors. - if (failedThisRun.has(file.id)) return; + if (failedThisRun.has(fileID)) return; const error = errorMessage(err); - errors.push({ - fileID: file.id, - title: file.metadata.title, - collection, - error, - }); - const prior = ledger.get(file.id); - ledger.set(file.id, { - fileID: file.id, - title: file.metadata.title, + errors.push({ fileID, title, collection, error }); + const prior = ledger.get(fileID); + ledger.set(fileID, { + fileID, + title, classification: classify(err), attempts: (prior?.attempts ?? 0) + 1, lastTriedAt: now, error, }); - failedThisRun.add(file.id); + failedThisRun.add(fileID); }; + // What the library could not decrypt. An album is logged by its ID, as + // its name is not known, so a run limited to some albums logs it too. A + // file in an album in scope is backed up from a copy of it in another + // album that decrypted, even an album out of scope. With no such copy it + // is recorded as failed, with an empty title as its metadata is not known. + const scope = new Set(collections.map((c) => c.id)); + const undecryptableAlbums: number[] = []; + for (const record of lib.undecryptable()) { + const error = `could not be decrypted: ${record.error}`; + if (record.fileID === undefined) { + log(`FAILED album ${record.collectionID}: ${error}`); + undecryptableAlbums.push(record.collectionID); + } else if (scope.has(record.collectionID)) { + const copies = memberships.get(record.fileID); + if (copies) { + distinct.set(record.fileID, representative(copies)); + } else { + recordFailure( + record.fileID, + "", + collectionName.get(record.collectionID) ?? "", + new Error(error), + ); + } + } + } + // Phase 1: get the bytes. Put each pending original at its save path // through the content cache/pools, as `Photo.download()` does, and fetch // the optional thumbnails; a present file is left as is. With `verify`, a @@ -636,7 +669,8 @@ const runLockedBackup = async ( `FAILED verifying original ${file.metadata.title}: ${errorMessage(err)}`, ); recordFailure( - file, + file.id, + file.metadata.title, collectionName.get(file.collectionID) ?? "", err, ); @@ -676,7 +710,8 @@ const runLockedBackup = async ( `FAILED original ${file.metadata.title}: ${errorMessage(err)}`, ); recordFailure( - file, + file.id, + file.metadata.title, collectionName.get(file.collectionID) ?? "", err, ); @@ -693,7 +728,8 @@ const runLockedBackup = async ( await copyAtomic(path, dest); } catch (err) { recordFailure( - file, + file.id, + file.metadata.title, collectionName.get(file.collectionID) ?? "", err, ); @@ -733,7 +769,8 @@ const runLockedBackup = async ( if (mlData === undefined && mlDataError !== undefined) { writeSidecar(path, file, { mlDataError }, image); recordFailure( - file, + file.id, + file.metadata.title, collectionName.get(file.collectionID) ?? "", new Error(`ML data: ${mlDataError}`), ); @@ -806,7 +843,12 @@ const runLockedBackup = async ( log( `FAILED symlink ${c.name}/${linkName}: ${errorMessage(err)}`, ); - recordFailure(link.file, c.name, err); + recordFailure( + link.file.id, + link.file.metadata.title, + c.name, + err, + ); } } @@ -837,6 +879,7 @@ const runLockedBackup = async ( unchecked, failed: ledger.size, errors, + undecryptableAlbums, }; }; diff --git a/src/cli-commands.ts b/src/cli-commands.ts index 154c180..076d1ef 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -470,7 +470,9 @@ export const backupCommand = async ( } } - return result.failed > 0 ? 1 : 0; + return result.failed > 0 || result.undecryptableAlbums.length > 0 + ? 1 + : 0; } finally { await lib.close(); } diff --git a/src/client.ts b/src/client.ts index 62784c1..bcb3dac 100644 --- a/src/client.ts +++ b/src/client.ts @@ -44,20 +44,28 @@ export interface ClientSnapshot { // The result of a resumable enumeration. Live decrypted records and deleted // ids are kept apart on purpose: a tombstone carries no key or metadata to -// decrypt, so it is a bare id rather than a hollowed-out record. `cursor` is -// the max `updationTime` seen, to pass back into the next call. +// decrypt, so it is a bare id rather than a hollowed-out record. A live row +// that cannot be decrypted, or whose metadata cannot be parsed, is in +// `undecryptable` with the reason instead, so one bad row does not fail the +// call; absent means there was none. `cursor` is the max `updationTime` seen, +// those rows included, to pass back into the next call. export interface CollectionsPage { collections: Collection[]; deleted: number[]; + undecryptable?: { id: number; error: string }[]; cursor: number; } export interface FilesPage { files: EnteFile[]; deleted: number[]; + undecryptable?: { id: number; error: string }[]; cursor: number; } +const errorMessage = (err: unknown): string => + err instanceof Error ? err.message : String(err); + export class Client { private readonly api: ApiClient; private readonly email: string; @@ -237,11 +245,12 @@ export class Client { } // Enumerate collections changed since `sinceTime`. Live collections are - // decrypted; tombstoned ones (isDeleted) are surfaced as bare ids. The - // returned cursor is the max `updationTime` seen — including tombstones, so - // the next sync resumes past them — falling back to `sinceTime` when the - // response is empty. `/collections/v2` returns the whole changed set in one - // response, so there is no pagination here. + // decrypted; tombstoned ones (isDeleted) are surfaced as bare ids, and + // ones that do not decrypt as ids with the reason. The returned cursor is + // the max `updationTime` seen — including tombstones, so the next sync + // resumes past them — falling back to `sinceTime` when the response is + // empty. `/collections/v2` returns the whole changed set in one response, + // so there is no pagination here. async collectionsSince(args: { sinceTime: number; }): Promise<CollectionsPage> { @@ -254,31 +263,40 @@ export class Client { const collections: Collection[] = []; const deleted: number[] = []; + const undecryptable: { id: number; error: string }[] = []; let cursor = args.sinceTime; for (const raw of raws) { if (raw.isDeleted) { deleted.push(raw.id); } else { - collections.push( - decryptCollection( - raw, - { - masterKey: this.masterKey, - publicKey: this.publicKey, - secretKey: this.secretKey, - }, - this.userID, - ), - ); + try { + collections.push( + decryptCollection( + raw, + { + masterKey: this.masterKey, + publicKey: this.publicKey, + secretKey: this.secretKey, + }, + this.userID, + ), + ); + } catch (err) { + undecryptable.push({ + id: raw.id, + error: errorMessage(err), + }); + } } if (raw.updationTime > cursor) cursor = raw.updationTime; } - return { collections, deleted, cursor }; + return { collections, deleted, undecryptable, cursor }; } // Enumerate a collection's files changed since `sinceTime`, paginating the // diff from that cursor. Live rows are decrypted; tombstoned ones are - // surfaced as bare ids. Returns the final cursor to resume from. + // surfaced as bare ids, and ones that do not decrypt or parse as ids with + // the reason. Returns the final cursor to resume from. async filesSince(args: { collectionID: number; collectionKey: Uint8Array; @@ -288,6 +306,7 @@ export class Client { const { collectionID, collectionKey } = args; const files: EnteFile[] = []; const deleted: number[] = []; + const undecryptable: { id: number; error: string }[] = []; let cursor = args.sinceTime; for (;;) { const { diff, hasMore } = await this.api.getJSON<{ @@ -300,7 +319,14 @@ export class Client { if (raw.isDeleted) { deleted.push(raw.id); } else { - files.push(decryptFile(raw, collectionKey)); + try { + files.push(decryptFile(raw, collectionKey)); + } catch (err) { + undecryptable.push({ + id: raw.id, + error: errorMessage(err), + }); + } } if (raw.updationTime > pageMax) pageMax = raw.updationTime; } @@ -321,18 +347,19 @@ export class Client { } cursor = pageMax; } - return { files, deleted, cursor }; + return { files, deleted, undecryptable, cursor }; } - // Whole-account listing: every live collection, deletions hidden. A thin - // wrapper over `collectionsSince` from the beginning of time. + // Whole-account listing: every live collection that decrypts, deletions + // hidden. A thin wrapper over `collectionsSince` from the beginning of + // time. async listCollections(): Promise<Collection[]> { const { collections } = await this.collectionsSince({ sinceTime: 0 }); return collections; } - // Every live file in a collection, deletions hidden. A thin wrapper over - // `filesSince` from the beginning of time. + // Every live file in a collection that decrypts, deletions hidden. A thin + // wrapper over `filesSince` from the beginning of time. async listFiles( collectionID: number, collectionKey: Uint8Array, diff --git a/src/library/index.ts b/src/library/index.ts index 080020b..443f4d4 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -601,6 +601,7 @@ export class Library { refresh: () => this.refreshNow(), listCollections: () => this.store.listCollections(), listFiles: (id) => this.store.listFiles(id), + undecryptable: () => this.store.listUndecryptable(), original: (fileID, destination) => cache!.backupOriginal(fileID, destination), thumbnail: (fileID) => cache!.thumbnail(fileID), @@ -703,7 +704,10 @@ export class Library { // Fetch every change since the stored cursor, then commit. All network // reads happen before any store mutation, so a fetch that throws leaves the - // store untouched and the previous snapshot intact. + // store untouched and the previous snapshot intact. A collection or file + // that does not decrypt is left out instead, as if deleted, with the reason + // kept in the store: the cursor moves past it, so no later refresh sees it + // again until the server's copy changes. private async refreshOnce(): Promise<void> { const page = await this.client.collectionsSince({ sinceTime: this.store.collectionsSinceTime, @@ -740,9 +744,17 @@ export class Library { this.store.deleteCollection(id); changed = true; } + if (this.store.deleteUndecryptable(id)) changed = true; + } + // A collection left out takes its files with it. + for (const { id, error } of page.undecryptable ?? []) { + this.store.deleteCollection(id); + this.store.putUndecryptable({ collectionID: id, error }); + changed = true; } for (const collection of page.collections) { this.store.putCollection(collection); + this.store.deleteUndecryptable(collection.id); changed = true; } @@ -752,9 +764,22 @@ export class Library { this.store.deleteFile(collectionID, id); changed = true; } + if (this.store.deleteUndecryptable(collectionID, id)) { + changed = true; + } + } + for (const { id, error } of filePage.undecryptable ?? []) { + this.store.deleteFile(collectionID, id); + this.store.putUndecryptable({ + collectionID, + fileID: id, + error, + }); + changed = true; } for (const f of filePage.files) { this.store.putFile(f); + this.store.deleteUndecryptable(collectionID, f.id); changed = true; } } diff --git a/src/library/store.ts b/src/library/store.ts index e74f8e1..fc59b78 100644 --- a/src/library/store.ts +++ b/src/library/store.ts @@ -2,10 +2,11 @@ // // The store keeps one `metadata.json` file holding the account's server // state: the user id, a schema version, the cursor for the incremental -// collections listing, and the decrypted collection and file records. The -// whole file is read into RAM on load and rewritten as a whole on save; there -// is no partial update and no lock file. A separate refresh unit populates the -// store from the server — this module only stores what it is given. +// collections listing, the decrypted collection and file records, and the +// collections and files the refresh could not decrypt. The whole file is read +// into RAM on load and rewritten as a whole on save; there is no partial +// update and no lock file. A separate refresh unit populates the store from +// the server — this module only stores what it is given. // // The file is a cache, so it is never trusted to exist or to be intact: a // missing or unreadable file loads as an empty store rather than an error, and @@ -33,12 +34,22 @@ const FILE_MODE = 0o600; type StoredCollection = Omit<Collection, "key"> & { key: string }; type StoredFile = Omit<EnteFile, "key"> & { key: string }; +// A collection, or with `fileID` a file in it, that the last refresh to fetch +// it could not decrypt or parse, and why. It is kept until a later refresh +// fetches it again, because a refresh fetches only what changed. +export interface Undecryptable { + collectionID: number; + fileID?: number; + error: string; +} + interface StoredMetadata { schemaVersion: number; userID: number; collectionsSinceTime: Microseconds; collections: StoredCollection[]; files: StoredFile[]; + undecryptable: Undecryptable[]; } const encodeKey = (key: Uint8Array): string => @@ -67,6 +78,7 @@ export class MetadataStore { private readonly collections = new Map<number, Collection>(); private readonly files = new Map<string, EnteFile>(); + private undecryptable: Undecryptable[] = []; private constructor(path: string) { this.path = path; @@ -107,6 +119,12 @@ export class MetadataStore { }; store.files.set(fileKey(file.collectionID, file.id), file); } + for (const record of parsed.undecryptable) { + if (typeof record.collectionID !== "number") { + throw new Error("damaged record of what did not decrypt"); + } + store.undecryptable.push(record); + } } catch { // Any corruption discards the partial result: a half-read cache is // worse than an empty one, since the refresh unit will rebuild it. @@ -134,6 +152,7 @@ export class MetadataStore { ...f, key: encodeKey(f.key), })), + undecryptable: this.undecryptable, }; const dir = dirname(this.path); @@ -165,7 +184,8 @@ export class MetadataStore { this.collections.set(collection.id, collection); } - // Removing a collection also drops its file memberships: a file record is + // Removing a collection also drops its file memberships, and any record + // that it or one of its files could not be decrypted: a file record is // only meaningful as part of a collection the cache still knows about. deleteCollection(id: number): void { this.collections.delete(id); @@ -174,6 +194,9 @@ export class MetadataStore { this.files.delete(key); } } + this.undecryptable = this.undecryptable.filter( + (record) => record.collectionID !== id, + ); } getFile(collectionID: number, fileID: number): EnteFile | undefined { @@ -205,4 +228,26 @@ export class MetadataStore { deleteFile(collectionID: number, fileID: number): void { this.files.delete(fileKey(collectionID, fileID)); } + + listUndecryptable(): Undecryptable[] { + return [...this.undecryptable]; + } + + // Replaces any earlier record of the same collection or file. + putUndecryptable(record: Undecryptable): void { + this.deleteUndecryptable(record.collectionID, record.fileID); + this.undecryptable.push(record); + } + + // Forgets the record of the collection, or with `fileID` of that file in + // it. Returns whether there was one. + deleteUndecryptable(collectionID: number, fileID?: number): boolean { + const before = this.undecryptable.length; + this.undecryptable = this.undecryptable.filter( + (record) => + record.collectionID !== collectionID || + record.fileID !== fileID, + ); + return this.undecryptable.length < before; + } } diff --git a/test/cli/backup-undecryptable.test.ts b/test/cli/backup-undecryptable.test.ts new file mode 100644 index 0000000..7344ba1 --- /dev/null +++ b/test/cli/backup-undecryptable.test.ts @@ -0,0 +1,554 @@ +/** + * `quak backup` over an account holding an album or a file quak cannot decrypt + * (issue 181). + * + * The server sends every album and file encrypted, and a refresh decrypts each + * one it fetches. A record whose key no key of the account opens, or whose + * metadata is not JSON, must not fail the refresh: a refresh that fails commits + * nothing, so `quak backup` would store nothing, on every run, until the + * server's copy changed. Instead the refresh leaves that record out, and the + * library keeps a list of what it left out in its cache. It has to: the next + * refresh asks the server only for what changed since the last one, so it will + * not see the record again. + * + * The backup stores every other file. It records each file it could not + * decrypt in `failures.json`, prints one line for each album it could not + * decrypt, and exits 1 while any remain. Once the server sends a copy that + * decrypts, or deletes the record, the next run drops it and can exit 0. The + * read commands answer from what did decrypt. + * + * These tests run the commands with a real saved session over a stand-in Ente + * server, which serves albums, files and originals encrypted with libsodium as + * the real server does. A record is broken the way a corrupt one arrives: its + * key replaced by bytes no key opens, or its metadata text that is not JSON. + */ + +import { + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, +} from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import sodium from "libsodium-wrappers-sumo"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +import { + backupCommand, + collectionsCommand, + filesCommand, + saveSession, + type CliContext, +} from "../../src/cli-commands.js"; +import { loadSession } from "../../src/cli-session.js"; +import { Client, type ClientSnapshot } from "../../src/client.js"; +import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js"; +import { Library } from "../../src/library/index.js"; + +const USER_ID = 42; + +// Every file is taken at noon local time on 2026-03-01, in microseconds as Ente +// stores times, so its original is saved in the folder `DAY` whatever the time +// zone of the machine running the tests. +const TAKEN = new Date(2026, 2, 1, 12).getTime() * 1000; +const DAY = join("2026", "2026-03", "2026-03-01"); + +type Row = { id: number; updationTime: number } & Record<string, unknown>; + +// `plaintext` sealed under `key` with a fresh nonce, both base64, as Ente +// sends a key or an album name. +const secretbox = ( + plaintext: Uint8Array, + key: Uint8Array, +): { ciphertext: string; nonce: string } => { + const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + return { + ciphertext: toBase64( + sodium.crypto_secretbox_easy(plaintext, nonce, key), + ), + nonce: toBase64(nonce), + }; +}; + +// What a corrupt row carries in place of its sealed key: random bytes of the +// same length, which no key opens. +const corruptKey = (): string => + toBase64( + sodium.randombytes_buf( + sodium.crypto_secretbox_KEYBYTES + sodium.crypto_secretbox_MACBYTES, + ), + ); + +const json = (body: unknown): Response => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + +// A stand-in Ente server for one account. It holds each album, and each file in +// an album, as the row `/collections/v2` and `/collections/v2/diff` serve, and +// answers a request with the rows whose `updationTime` is past the request's +// `sinceTime`, as the real server does. A change to a file also moves its +// album's `updationTime`, which is what tells a client to list the album's +// files again. Originals are served by file ID, as the files CDN serves them. +class FakeEnte { + private readonly masterKey = sodium.crypto_secretbox_keygen(); + private readonly keyPair = sodium.crypto_box_keypair(); + private readonly albums = new Map<number, Row>(); + private readonly albumKeys = new Map<number, Uint8Array>(); + // Each album's file rows, by album ID and then file ID. + private readonly files = new Map<number, Map<number, Row>>(); + // Each file's key and its original as served, by file ID. A file in two + // albums is one original under one key, sealed under each album's key. + private readonly originals = new Map< + number, + { key: Uint8Array; header: string; body: Uint8Array } + >(); + + // The session `quak login` would have saved for the account. + snapshot(): ClientSnapshot { + return { + email: "user@example.com", + userID: USER_ID, + token: "token", + masterKey: toBase64(this.masterKey), + secretKey: toBase64(this.keyPair.privateKey), + publicKey: toBase64(this.keyPair.publicKey), + }; + } + + // Album `id`, named `name`, as of time `t`. With `corrupt`, its key cannot + // be decrypted. An album keeps its key from one call to the next, so its + // files' rows still decrypt once it is put back without `corrupt`. + putAlbum(id: number, name: string, t: number, corrupt = false): void { + const key = this.albumKeys.get(id) ?? sodium.crypto_secretbox_keygen(); + this.albumKeys.set(id, key); + const sealedKey = secretbox(key, this.masterKey); + const sealedName = secretbox(new TextEncoder().encode(name), key); + this.albums.set(id, { + id, + owner: { id: USER_ID }, + encryptedKey: corrupt ? corruptKey() : sealedKey.ciphertext, + keyDecryptionNonce: sealedKey.nonce, + encryptedName: sealedName.ciphertext, + nameDecryptionNonce: sealedName.nonce, + type: "album", + updationTime: t, + }); + if (!this.files.has(id)) this.files.set(id, new Map()); + } + + // File `id`, titled `photo-<id>.jpg`, in album `albumID` as of time `t`. + // With `corruptKey`, its key cannot be decrypted. With `metadata`, its + // metadata decrypts to that text instead of the JSON a client uploads. + putFile( + albumID: number, + id: number, + t: number, + opts: { corruptKey?: boolean; metadata?: string } = {}, + ): void { + const original = this.originalOf(id); + const sealedKey = secretbox(original.key, this.albumKeys.get(albumID)!); + const metadata = + opts.metadata ?? + JSON.stringify({ + title: `photo-${id}.jpg`, + fileType: 0, + creationTime: TAKEN, + modificationTime: TAKEN, + }); + const sealedMetadata = encryptBlob( + new TextEncoder().encode(metadata), + original.key, + ); + this.files.get(albumID)!.set(id, { + id, + collectionID: albumID, + ownerID: USER_ID, + encryptedKey: opts.corruptKey ? corruptKey() : sealedKey.ciphertext, + keyDecryptionNonce: sealedKey.nonce, + metadata: { + encryptedData: toBase64(sealedMetadata.ciphertext), + decryptionHeader: toBase64(sealedMetadata.header), + }, + file: { decryptionHeader: original.header }, + thumbnail: { decryptionHeader: original.header }, + updationTime: t, + }); + this.moveAlbum(albumID, t); + } + + // A deleted record stays on the server as a tombstone holding only its + // ID, its time and `isDeleted`. + deleteFile(albumID: number, id: number, t: number): void { + this.files + .get(albumID)! + .set(id, { id, updationTime: t, isDeleted: true }); + this.moveAlbum(albumID, t); + } + + deleteAlbum(id: number, t: number): void { + this.albums.set(id, { id, updationTime: t, isDeleted: true }); + } + + // The `fetch` the saved session's client is given. + readonly fetch = (async (input: string | URL | Request) => { + const url = new URL(input instanceof Request ? input.url : input); + const since = Number(url.searchParams.get("sinceTime")); + const changed = (rows: Iterable<Row>): Row[] => + [...rows].filter((row) => row.updationTime > since); + switch (url.pathname) { + case "/collections/v2": + return json({ collections: changed(this.albums.values()) }); + case "/collections/v2/diff": { + const albumID = Number(url.searchParams.get("collectionID")); + const rows = this.files.get(albumID)?.values() ?? []; + return json({ diff: changed(rows), hasMore: false }); + } + case "/files/data/fetch": + // No file has ML data. + return json({ data: [] }); + default: { + const fileID = Number(url.searchParams.get("fileID")); + const original = this.originals.get(fileID); + return original + ? new Response(original.body) + : new Response("not found", { status: 404 }); + } + } + }) as typeof globalThis.fetch; + + private originalOf(id: number): { + key: Uint8Array; + header: string; + body: Uint8Array; + } { + const known = this.originals.get(id); + if (known) return known; + const key = sodium.crypto_secretbox_keygen(); + const { header, ciphertext } = encryptBlob( + new TextEncoder().encode(`original of ${id}`), + key, + ); + const original = { key, header: toBase64(header), body: ciphertext }; + this.originals.set(id, original); + return original; + } + + private moveAlbum(albumID: number, t: number): void { + this.albums.set(albumID, { + ...this.albums.get(albumID)!, + updationTime: t, + }); + } +} + +// Album 1, "Trip", holds file 10, which decrypts, file 11, whose key does not, +// and file 12, whose metadata is not JSON. Album 2, "Family", holds file 20, +// but the album's own key does not decrypt. All of it as of time 1. +const brokenAccount = (): FakeEnte => { + const ente = new FakeEnte(); + ente.putAlbum(1, "Trip", 1); + ente.putFile(1, 10, 1); + ente.putFile(1, 11, 1, { corruptKey: true }); + ente.putFile(1, 12, 1, { metadata: "not json" }); + ente.putAlbum(2, "Family", 1, true); + ente.putFile(2, 20, 1); + return ente; +}; + +// Album 1, "Trip", holds file 10, and album 2, "Family", holds file 20. Both +// files decrypt, but album 2's own key does not, so the album is the only +// record left out. All of it as of time 1. +const brokenAlbumAccount = (): FakeEnte => { + const ente = new FakeEnte(); + ente.putAlbum(1, "Trip", 1); + ente.putFile(1, 10, 1); + ente.putAlbum(2, "Family", 1, true); + ente.putFile(2, 20, 1); + return ente; +}; + +// Collects everything written to it. +class Output { + text = ""; + write(text: string): void { + this.text += text; + } +} + +let root: string; +let stdout: Output; +let stderr: Output; + +beforeAll(async () => { + await init(); + await sodium.ready; +}); + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "quak-undecryptable-")); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +// A context for one command, with fresh output, whose saved session talks to +// `ente`. Every run shares one cache directory, as runs of `quak` do. +const context = (ente: FakeEnte): CliContext => { + stdout = new Output(); + stderr = new Output(); + saveSession(join(root, "session"), ente.snapshot()); + return { + stdout, + stderr, + sessionDir: join(root, "session"), + cacheDir: join(root, "cache"), + loadSession: (path) => + loadSession(path, { fetch: ente.fetch, retry: { attempts: 1 } }), + login: async () => { + throw new Error("login not expected"); + }, + prompt: async () => { + throw new Error("prompt not expected"); + }, + promptSecret: async () => { + throw new Error("prompt not expected"); + }, + }; +}; + +const backupDir = (): string => join(root, "backup"); + +// Runs `quak backup` into the backup directory and returns its exit code. +const backup = (ente: FakeEnte, opts: { json?: boolean } = {}) => + backupCommand(context(ente), backupDir(), opts); + +// The backup's `failures.json` entries by file ID, or {} when it has none. +const failures = (): Record<string, { error: string; attempts: number }> => { + const path = join(backupDir(), "failures.json"); + return existsSync(path) + ? JSON.parse(readFileSync(path, "utf-8")).files + : {}; +}; + +// Whether file `id`'s original is at its save path in the backup. +const stored = (id: number): boolean => + existsSync(join(backupDir(), DAY, `2026-03-01.${id}.jpg`)); + +// The IDs of the files that album `name`'s JSON lists. +const albumFiles = (name: string): number[] => + JSON.parse( + readFileSync(join(backupDir(), "collections", `${name}.json`), "utf-8"), + ).files.map((f: { id: number }) => f.id); + +describe("quak backup with records it cannot decrypt", () => { + it("backs up every other file, records each file it cannot decrypt in failures.json, names each such album, and exits 1", async () => { + expect(await backup(brokenAccount())).toBe(1); + + expect(stored(10)).toBe(true); + // Without its album's key, file 20 cannot even be listed. + expect(stored(20)).toBe(false); + expect(failures()).toEqual({ + "11": expect.objectContaining({ + fileID: 11, + error: expect.stringMatching( + /^could not be decrypted: .*wrong secret key/, + ), + }), + "12": expect.objectContaining({ + fileID: 12, + error: expect.stringMatching(/^could not be decrypted: .*JSON/), + }), + }); + expect(stderr.text).toMatch( + /^FAILED album 2: could not be decrypted: .*wrong secret key/m, + ); + expect(stderr.text).toContain(" Failed: 2\n"); + }); + + it("names each album it cannot decrypt in the --json result", async () => { + expect(await backup(brokenAccount(), { json: true })).toBe(1); + + expect(JSON.parse(stdout.text)).toMatchObject({ + downloaded: 1, + failed: 2, + undecryptableAlbums: [2], + }); + }); + + it("still reports them on the next run, though the server does not send them again", async () => { + const ente = brokenAccount(); + await backup(ente); + + // Nothing changed on the server, so this run's refresh gets no rows at + // all. What it reports, it remembered from the first run. + expect(await backup(ente)).toBe(1); + + expect(Object.keys(failures())).toEqual(["11", "12"]); + expect(failures()["11"]!.attempts).toBe(2); + expect(stderr.text).toMatch(/^FAILED album 2: could not be decrypted/m); + }); + + it("drops each once the server sends a copy that decrypts, and then exits 0", async () => { + const ente = brokenAccount(); + await backup(ente); + + // At time 2 files 11 and 12 and album 2 change on the server, and now + // each decrypts. Album 2 brings file 20 with it. + ente.putFile(1, 11, 2); + ente.putFile(1, 12, 2); + ente.putAlbum(2, "Family", 2); + expect(await backup(ente)).toBe(0); + + expect([11, 12, 20].map(stored)).toEqual([true, true, true]); + expect(existsSync(join(backupDir(), "failures.json"))).toBe(false); + expect(stderr.text).not.toContain("FAILED"); + }); + + it("drops each once the server deletes it, and then exits 0", async () => { + const ente = brokenAccount(); + await backup(ente); + + ente.deleteFile(1, 11, 2); + ente.deleteFile(1, 12, 2); + ente.deleteAlbum(2, 2); + expect(await backup(ente)).toBe(0); + + expect(existsSync(join(backupDir(), "failures.json"))).toBe(false); + expect(stderr.text).not.toContain("FAILED"); + }); + + it("exits 1 when the only record it cannot decrypt is an album, and 0 once the album decrypts", async () => { + const ente = brokenAlbumAccount(); + + // No file is in `failures.json`: the album alone keeps the exit code + // at 1. + expect(await backup(ente)).toBe(1); + expect(failures()).toEqual({}); + + ente.putAlbum(2, "Family", 2); + expect(await backup(ente)).toBe(0); + expect(stored(20)).toBe(true); + }); + + it("exits 1 when the only record it cannot decrypt is an album, and 0 once the album is deleted", async () => { + const ente = brokenAlbumAccount(); + + expect(await backup(ente)).toBe(1); + expect(failures()).toEqual({}); + + ente.deleteAlbum(2, 2); + expect(await backup(ente)).toBe(0); + }); + + it("removes what an earlier run linked for an album or a file that no longer decrypts, and keeps the originals", async () => { + // At time 1 everything decrypts: the first run links files 10 and 11 + // in Trip and file 20 in Family. + const ente = new FakeEnte(); + ente.putAlbum(1, "Trip", 1); + ente.putFile(1, 10, 1); + ente.putFile(1, 11, 1); + ente.putAlbum(2, "Family", 1); + ente.putFile(2, 20, 1); + expect(await backup(ente)).toBe(0); + const collections = join(backupDir(), "collections"); + expect(readdirSync(join(collections, "Trip")).sort()).toEqual([ + "photo-10.jpg", + "photo-11.jpg", + ]); + expect(readdirSync(join(collections, "Family"))).toEqual([ + "photo-20.jpg", + ]); + + // At time 2 the server's copy of file 11 in Trip, and album 2 itself, + // no longer decrypt. A run builds the album folders, their JSON and + // their links from what the library holds, so it removes them as if + // the album and the file had been deleted: Family's folder and JSON + // go, and Trip neither links nor lists file 11. The originals stay at + // their save paths, and the run exits 1. + ente.putFile(1, 11, 2, { corruptKey: true }); + ente.putAlbum(2, "Family", 2, true); + expect(await backup(ente)).toBe(1); + + expect(readdirSync(collections).sort()).toEqual(["Trip", "Trip.json"]); + expect(readdirSync(join(collections, "Trip"))).toEqual([ + "photo-10.jpg", + ]); + expect(albumFiles("Trip")).toEqual([10]); + expect([10, 11, 20].map(stored)).toEqual([true, true, true]); + expect(Object.keys(failures())).toEqual(["11"]); + }); + + it("backs up a file that does not decrypt in one album but does in another, and does not report it", async () => { + // File 30 is in albums 1 and 3, each holding its key sealed under the + // album's own key. In album 1 that is corrupt; in album 3 it is not, + // and one copy that decrypts is all the backup needs. + const ente = new FakeEnte(); + ente.putAlbum(1, "Trip", 1); + ente.putFile(1, 30, 1, { corruptKey: true }); + ente.putAlbum(3, "Favorites", 1); + ente.putFile(3, 30, 1); + + expect(await backup(ente)).toBe(0); + + expect(stored(30)).toBe(true); + expect(failures()).toEqual({}); + // Album 1 has no copy of file 30 that decrypts, so it neither links + // the original nor lists the file; album 3 lists it. + expect(readdirSync(join(backupDir(), "collections", "Trip"))).toEqual( + [], + ); + expect(albumFiles("Trip")).toEqual([]); + expect(albumFiles("Favorites")).toEqual([30]); + }); + + it("backs up that file in a run limited to the album where it does not decrypt", async () => { + // The same account, backed up by `lib.backup()` limited to album 1, + // "Trip", as `quak backup` cannot be. File 30 is in Trip, so the run + // must store it, from its copy in album 3 that the run otherwise + // skips. + const ente = new FakeEnte(); + ente.putAlbum(1, "Trip", 1); + ente.putFile(1, 30, 1, { corruptKey: true }); + ente.putAlbum(3, "Favorites", 1); + ente.putFile(3, 30, 1); + const lib = await Library.open({ + client: Client.fromJSON(ente.snapshot(), { + fetch: ente.fetch, + retry: { attempts: 1 }, + }), + downloadDirectory: backupDir(), + cacheDirectory: join(root, "cache"), + precacheThumbnails: false, + precacheOriginals: false, + }); + try { + const result = await lib.backup({ + downloadDirectory: backupDir(), + onlyAlbumNames: ["Trip"], + }); + expect(result).toMatchObject({ downloaded: 1, failed: 0 }); + } finally { + await lib.close(); + } + + expect(stored(30)).toBe(true); + expect(failures()).toEqual({}); + }); +}); + +describe("the read commands with records quak cannot decrypt", () => { + it("quak collections and quak files answer from what decrypts", async () => { + const ente = brokenAccount(); + + expect(await collectionsCommand(context(ente), {})).toBe(0); + expect(stdout.text).toBe("1\talbum\tTrip\n"); + + expect(await filesCommand(context(ente), { collection: "1" })).toBe(0); + expect(stdout.text).toBe("10\timage\tphoto-10.jpg\n"); + }); +}); diff --git a/test/cli/backup.test.ts b/test/cli/backup.test.ts index 977a584..065a2c5 100644 --- a/test/cli/backup.test.ts +++ b/test/cli/backup.test.ts @@ -1482,6 +1482,7 @@ describe("backup album folders", () => { listCollections: () => albums.map((a) => a.collection), listFiles: (id) => albums.find((a) => a.collection.id === id)?.files ?? [], + undecryptable: () => [], original: async (fileID) => { const path = join(root, `source-${fileID}`); writeFileSync(path, `original ${fileID}`); diff --git a/test/client/enumeration.test.ts b/test/client/enumeration.test.ts index 363f27d..49fcb07 100644 --- a/test/client/enumeration.test.ts +++ b/test/client/enumeration.test.ts @@ -10,7 +10,9 @@ * The return shape keeps live records and tombstones apart — `collections` / * `files` are decrypted live records, `deleted` is a plain list of the ids the * server tombstoned. A tombstone carries no decryptable key or metadata, so it - * is a bare id rather than a hollowed-out `Collection` / `EnteFile`. + * is a bare id rather than a hollowed-out `Collection` / `EnteFile`. A live row + * that does not decrypt, or whose metadata is not JSON, is listed under + * `undecryptable` with the reason, so one bad row does not fail the call. * * All tests inject a fake `fetch` and drive a real `Client` (built with * `Client.fromJSON`) so the decryption path runs for real. Live rows are built @@ -91,27 +93,38 @@ const ownedCollectionRow = ( }; }; -/** A live file row inside a collection, keyed under that collection's key. */ +/** + * A live file row inside a collection, keyed under that collection's key. Its + * metadata decrypts to `metadata` when given, otherwise to the JSON a client + * uploads for `title`. + */ const fileRow = ( collectionKey: Uint8Array, - opts: { id: number; title: string; updationTime: number }, + opts: { + id: number; + title: string; + updationTime: number; + metadata?: string; + }, ): Record<string, unknown> => { const fileKey = sodium.crypto_secretbox_keygen(); const { ciphertext: encFileKey, nonce: fileKeyNonce } = secretboxEncrypt( fileKey, collectionKey, ); - const metadata = { - title: opts.title, - fileType: 0, - creationTime: opts.updationTime, - modificationTime: opts.updationTime, - }; + const metadata = + opts.metadata ?? + JSON.stringify({ + title: opts.title, + fileType: 0, + creationTime: opts.updationTime, + modificationTime: opts.updationTime, + }); const push = sodium.crypto_secretstream_xchacha20poly1305_init_push(fileKey); const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push( push.state, - new TextEncoder().encode(JSON.stringify(metadata)), + new TextEncoder().encode(metadata), null, sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, ); @@ -131,6 +144,21 @@ const fileRow = ( }; }; +/** + * `row` with its sealed key replaced by random bytes of the same length, as a + * corrupt row arrives: no key opens it, so the row cannot be decrypted. + */ +const withCorruptKey = ( + row: Record<string, unknown>, +): Record<string, unknown> => ({ + ...row, + encryptedKey: toBase64( + sodium.randombytes_buf( + sodium.crypto_secretbox_KEYBYTES + sodium.crypto_secretbox_MACBYTES, + ), + ), +}); + /** A tombstone row. Never decrypted, so only these fields are ever read. */ const tombstoneRow = ( id: number, @@ -278,6 +306,58 @@ describe("Client.filesSince", () => { // It gave up after the first page rather than looping. expect(sinceTimes).toEqual(["50"]); }); + + it("leaves out each file it cannot decrypt or parse, with the reason, and decrypts the rest", async () => { + const keys = buildKeys(); + const collectionKey = sodium.crypto_secretbox_keygen(); + + // File 1002's key is sealed under no key quak holds, and file 1003's + // metadata decrypts to text that is not JSON. Neither may cost the + // caller file 1001: each is left out of `files` and reported under + // `undecryptable` with the reason instead of failing the call. + const { fetch } = recordingFetch( + jsonResponse({ + diff: [ + fileRow(collectionKey, { + id: 1001, + title: "fine.jpg", + updationTime: 100, + }), + withCorruptKey( + fileRow(collectionKey, { + id: 1002, + title: "bad-key.jpg", + updationTime: 200, + }), + ), + fileRow(collectionKey, { + id: 1003, + title: "bad-metadata.jpg", + updationTime: 300, + metadata: "not json", + }), + ], + hasMore: false, + }), + ); + const client = Client.fromJSON(snapshotFor(keys), { fetch }); + + const { files, undecryptable, cursor } = await client.filesSince({ + collectionID: 1, + collectionKey, + sinceTime: 0, + }); + + expect(files.map((f) => f.id)).toEqual([1001]); + expect(undecryptable).toEqual([ + { id: 1002, error: expect.stringContaining("wrong secret key") }, + { id: 1003, error: expect.stringContaining("JSON") }, + ]); + // The cursor moves past the two left out, as past any other row, so + // the server does not send them again until its copy changes. The + // caller has to keep what it was told here. + expect(cursor).toBe(300); + }); }); describe("Client.collectionsSince", () => { @@ -331,6 +411,42 @@ describe("Client.collectionsSince", () => { expect(result.cursor).toBe(150); expect(sinceTimes).toEqual(["150"]); }); + + it("leaves out an album it cannot decrypt, with the reason, and decrypts the rest", async () => { + const keys = buildKeys(); + + // Album 2's key is sealed under no key quak holds. Without its key + // neither its name nor its files can be read, so it is left out and + // reported under `undecryptable`; album 1 is unaffected. + const { fetch } = recordingFetch( + jsonResponse({ + collections: [ + ownedCollectionRow(keys.masterKey, { + id: 1, + name: "Vacation", + updationTime: 100, + }), + withCorruptKey( + ownedCollectionRow(keys.masterKey, { + id: 2, + name: "Broken", + updationTime: 200, + }), + ), + ], + }), + ); + const client = Client.fromJSON(snapshotFor(keys), { fetch }); + + const { collections, undecryptable, cursor } = + await client.collectionsSince({ sinceTime: 0 }); + + expect(collections.map((c) => c.name)).toEqual(["Vacation"]); + expect(undecryptable).toEqual([ + { id: 2, error: expect.stringContaining("wrong secret key") }, + ]); + expect(cursor).toBe(200); + }); }); describe("Client list wrappers still hide deletions", () => { diff --git a/test/library/store.test.ts b/test/library/store.test.ts index 21212b3..38e7e25 100644 --- a/test/library/store.test.ts +++ b/test/library/store.test.ts @@ -172,6 +172,29 @@ describe("MetadataStore", () => { expect(store.listCollections()).toEqual([]); }); + it("loads an empty store when the list of what the refresh could not decrypt is damaged", async () => { + // A backup reads that list on every run, so a damaged one that loaded + // would fail every backup until the cache was deleted. A string is + // damaged too, though JavaScript can loop over its characters. + mkdirSync(join(dir, "cache"), { recursive: true }); + for (const undecryptable of [{}, "not a list", [1]]) { + writeFileSync( + path, + JSON.stringify({ + schemaVersion: METADATA_SCHEMA_VERSION, + userID: 42, + collectionsSinceTime: 0, + collections: [], + files: [], + undecryptable, + }), + ); + const store = await MetadataStore.load(path); + expect(store.userID).toBe(0); + expect(store.listUndecryptable()).toEqual([]); + } + }); + it("creates the directory 0700 and the file 0600", async () => { const store = await MetadataStore.load(path); store.putCollection(sampleCollection()); From e8e8e00887510fcc993ff20a679522dccfc5a9bd Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 14 Oct 2026 07:37:26 +0200 Subject: [PATCH 27/39] A backup whose lock is lost stops with one line, not a stack trace (closes #184) proper-lockfile's default onCompromised throws from a timer, so a backup whose backup.lock was removed or taken over by another backup died with a stack trace. lockBackupDirectory now returns the lock, its release function and why it was lost, and passes a handler that only records the reason. The backup checks it after the refresh and before each original, thumbnail, file's JSON and album, and before it writes failures.json, and then fails with "lost the lock on DIR: REASON", which quak backup prints as one line, exiting 1. lib.backup() takes a caller's lock as lock, in place of lockHeld: true. Releasing a lost lock leaves backup.lock alone, since it may be another backup's. Model: opus-5-5 --- README.md | 51 +++++--- TODO.md | 10 ++ src/backup.ts | 82 +++++++++--- src/cli-commands.ts | 10 +- src/index.ts | 1 + src/library/index.ts | 8 +- test/cli/backup-end-to-end.test.ts | 201 ++++++++++++++++++++++++++++- 7 files changed, 313 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 0a7e7a3..bc74537 100644 --- a/README.md +++ b/README.md @@ -761,9 +761,9 @@ drops it. `backup.lock` keeps two backups of the same directory from running at once, such as a cron run that starts while the previous one is still going. A backup creates `<dir>` if it is missing and takes the lock before its refresh, and -removes the lock when it ends, whether it succeeds or fails; `quak backup` takes -it before it opens its library, so a refused run sends no request. The lock is a -directory that +removes the lock when it ends, whether it succeeds or fails, unless it lost the +lock (see below); `quak backup` takes it before it opens its library, so a +refused run sends no request. The lock is a directory that [proper-lockfile](https://github.com/moxystudio/node-proper-lockfile) creates and keeps touching while the backup runs. A second backup of the directory, from another process or the same one, fails at once: `quak backup` prints @@ -775,6 +775,16 @@ over, so nobody has to remove it. The lock is outside the date folders and `collections/`, so it is never taken for an original, and the removal of old album directories never touches it. +A running backup loses its lock when someone removes `backup.lock`, or when +another backup takes it over because the running one left it untouched for 10 +seconds, as a stopped or stalled process does. The backup finds this out the +next time it goes to touch the lock, which it does every 5 seconds, and then +stops before it starts another file. The original it is downloading at that +moment is still stored. It leaves `failures.json` and `backup.lock` as they are, +since another backup may be using them. `quak backup` prints +`quak: lost the lock on <dir>: <reason>` as one line and exits 1, and +`lib.backup()` rejects with `lost the lock on <dir>: <reason>`. + A collection's directory and JSON are named after the collection, and a symlink after the file's title, both with unsafe characters replaced. When two collections would get the same name, or two symlinks in one collection the same @@ -1060,21 +1070,24 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange` directory, and fails at once with an error whose `code` is `ELOCKED` while another backup of it runs. A caller can instead take the lock itself, before it opens its library, as `quak backup` does: `await lockBackupDirectory(dir)` - takes it, failing the same way, and returns the function that releases it, and - the caller passes `lockHeld: true` to the backup. It waits for a refresh as - `fresh()` does, puts every in-scope original not already at its save path - there as `photo.download()` does (and, with `includeThumbnails`, fetches - thumbnails) through the content cache, waits for an ML data fetch, and - rebuilds the on-disk backup tree with a durable failure ledger. Each file's - JSON holds its ML data and, for an image (for a live photo, its image), its - original's EXIF and XMP, with its dimensions for a JPEG only; a video's JSON - holds none of these three. A fetched original is written straight to its save - path and not into the cache, which then counts it as present; one the cache - already held is copied from there. `BackupOptions`: `downloadDirectory` (falls - back to the library's), `includeOriginals` (default `true`), - `includeThumbnails` (default `false`), `onlyAlbumNames`, `verify` (default - `false`), `onProgress`, and `lockHeld` (default `false`). See Backup layout - above for the tree it writes. + takes it, failing the same way, and returns a `BackupLock`, whose `release()` + removes the lock unless it was lost and whose `lost` holds why it was lost. + The caller passes it to the backup as `lock`, and releases it once the backup + ends. It waits for a refresh as `fresh()` does, puts every in-scope original + not already at its save path there as `photo.download()` does (and, with + `includeThumbnails`, fetches thumbnails) through the content cache, waits for + an ML data fetch, and rebuilds the on-disk backup tree with a durable failure + ledger. Each file's JSON holds its ML data and, for an image (for a live + photo, its image), its original's EXIF and XMP, with its dimensions for a JPEG + only; a video's JSON holds none of these three. A fetched original is written + straight to its save path and not into the cache, which then counts it as + present; one the cache already held is copied from there. `BackupOptions`: + `downloadDirectory` (falls back to the library's), `includeOriginals` (default + `true`), `includeThumbnails` (default `false`), `onlyAlbumNames`, `verify` + (default `false`), `onProgress`, and `lock`. See Backup layout above for the + tree it writes. Whether the backup took the lock or its caller did, once the + lock is lost the backup rejects with `lost the lock on <dir>: <reason>` before + it starts another file. ### Request pools @@ -1134,7 +1147,7 @@ from a very old client, is stored unchecked. - `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult` - `src/exif.ts`: `ExifTags`, `PhotoExif` - `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool` -- `src/backup.ts`: `BackupOptions`, `BackupResult`, `BackupError` +- `src/backup.ts`: `BackupLock`, `BackupOptions`, `BackupResult`, `BackupError` - `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot` - `src/api/client.ts`: `ApiClient`, `ApiClientOptions`, `StreamOptions` - `src/errors.ts`: `ApiError`, `TruncatedStreamError` diff --git a/TODO.md b/TODO.md index e170323..5f555ea 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,16 @@ declares one. # Completed Steps +- 2026-10-14: A backup whose lock is lost no longer dies with a stack trace + (issue 184). `lockBackupDirectory` gives proper-lockfile an `onCompromised` + that records why the lock was lost instead of throwing from its timer, and + returns the lock, which `quak backup` passes to `lib.backup()` as `lock`. The + backup then stops before it starts another file with + `lost the lock on DIR: REASON`, which `quak backup` prints as one line before + it exits 1. Releasing a lost lock leaves `backup.lock` alone. Tests in + `test/cli/backup-end-to-end.test.ts` remove the lock, or put another backup's + in its place, while a download is held open. + - 2026-10-14: An album or file quak cannot decrypt, or whose metadata is not JSON, no longer fails the refresh, so one bad record no longer stops `quak backup` from storing anything (issue 181). The refresh leaves it out, an diff --git a/src/backup.ts b/src/backup.ts index 5deae14..bd76d2f 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -1,8 +1,9 @@ // The backup command, rebuilt on the library API (issue #51). // // `lib.backup()` takes the lock in `downloadDirectory` (unless its caller -// already holds it), and fails at once when another backup of it holds the -// lock. It waits for a completed refresh of the library (a failed one fails the +// passes one in), and fails at once when another backup of it holds the +// lock; when the lock is lost while it runs, it fails before it starts another +// file. It waits for a completed refresh of the library (a failed one fails the // backup before any file is touched), then, for every file in scope, puts its // original at its save path under `downloadDirectory`, as `Photo.download()` // does, waits for an ML data fetch, and rebuilds the derived views (per-file @@ -96,6 +97,14 @@ import type { Collection, EnteFile, FileMetadata } from "./model/types.js"; export type ProgressCallback = (message: string) => void; +// The lock in a backup directory, as `lockBackupDirectory` returns it. +export interface BackupLock { + // Removes `backup.lock`, unless the lock was lost. + release(): Promise<void>; + // Why the lock was lost, once it is. + lost?: Error; +} + export interface BackupOptions { // Where the backup tree lives. `lib.backup()` defaults it to the library's // download directory; `runBackup` with none throws before any network @@ -112,10 +121,11 @@ export interface BackupOptions { // bytes do not match the content hash its metadata records. Default false. verify?: boolean; onProgress?: ProgressCallback; - // The caller already holds the lock in `downloadDirectory`, taken with - // `lockBackupDirectory`, and releases it itself, so the backup does not - // take it. `quak backup` takes it before it opens its library. - lockHeld?: boolean; + // The lock in `downloadDirectory`, when the caller took it with + // `lockBackupDirectory` and releases it itself. The backup then does not + // take the lock, and fails once this one is lost. `quak backup` takes it + // before it opens its library. + lock?: BackupLock; } export interface BackupError { @@ -510,11 +520,14 @@ const writeAlbumJSON = ( writeFileSync(path, JSON.stringify(album, null, 2)); }; -// The backup itself, which `runBackup` below runs while the lock is held. +// The backup itself, which `runBackup` below runs while `lock` is held. Once +// the lock is lost, another backup may be writing to `downloadDirectory`, so +// this one fails before it starts another file. const runLockedBackup = async ( lib: BackupLibrary, opts: BackupOptions, downloadDirectory: string, + lock: BackupLock, ): Promise<BackupResult> => { const includeOriginals = opts.includeOriginals ?? true; const includeThumbnails = opts.includeThumbnails ?? false; @@ -524,6 +537,7 @@ const runLockedBackup = async ( log("Refreshing library..."); await lib.refresh(); + if (lock.lost) throw lock.lost; const collectionsDir = join(downloadDirectory, "collections"); const thumbnailsDir = join(downloadDirectory, "thumbnails"); @@ -642,6 +656,7 @@ const runLockedBackup = async ( // that cannot be read is recorded as failed and left where it is. if (includeOriginals) { for (const [fileID, file] of distinct) { + if (lock.lost) throw lock.lost; let stored = storedAtSavePath(downloadDirectory, file); let mismatch = false; if (stored !== undefined && verify) { @@ -721,6 +736,7 @@ const runLockedBackup = async ( if (includeThumbnails) { for (const [fileID, file] of distinct) { + if (lock.lost) throw lock.lost; const dest = join(thumbnailsDir, `${fileID}.jpg`); if (isPresent(dest)) continue; try { @@ -753,6 +769,7 @@ const runLockedBackup = async ( log(`FAILED ML data: ${mlDataError}`); } for (const file of distinct.values()) { + if (lock.lost) throw lock.lost; const stored = storedAtSavePath(downloadDirectory, file); if (stored === undefined) continue; const path = withExtension( @@ -796,6 +813,7 @@ const runLockedBackup = async ( const albumDirNames = new Map( allCollections.map((c, i) => [c.id, dirNames[i]!]), ); + if (lock.lost) throw lock.lost; try { removeStaleAlbumDirs( collectionsDir, @@ -807,6 +825,7 @@ const runLockedBackup = async ( } for (const c of collections) { + if (lock.lost) throw lock.lost; const colDirName = albumDirNames.get(c.id)!; const colDir = join(collectionsDir, colDirName); mkdirSync(colDir, { recursive: true }); @@ -864,7 +883,9 @@ const runLockedBackup = async ( // its failure resolved; a file gone from the library (deleted) or outside // this run's scope is not something this run can resolve, so keeping its // stale entry would keep the exit code non-zero forever — a single - // since-deleted photo would fail every future scheduled backup. + // since-deleted photo would fail every future scheduled backup. A backup + // whose lock was lost leaves the ledger as it is. + if (lock.lost) throw lock.lost; for (const fileID of [...ledger.keys()]) { if (!failedThisRun.has(fileID)) ledger.delete(fileID); } @@ -885,18 +906,36 @@ const runLockedBackup = async ( // Only one backup of a directory runs at a time, in this process or another. // Creates `downloadDirectory` if it is missing, takes the lock in it and -// returns the function that releases it; while another backup holds the lock, -// fails at once with an error whose `code` is `ELOCKED`. The lock is the -// directory `backup.lock`, whose modification time proper-lockfile keeps -// current while it is held. One it has not touched for 10 seconds was left by -// a run that could not remove it, and is taken over. +// returns it; while another backup holds the lock, fails at once with an error +// whose `code` is `ELOCKED`. The lock is the directory `backup.lock`, whose +// modification time proper-lockfile keeps current while it is held. One it has +// not touched for 10 seconds was left by a run that could not remove it, and +// is taken over. +// +// proper-lockfile finds the lock lost when `backup.lock` is gone or is another +// backup's, and calls `onCompromised` from a timer, where throwing would end +// the process. The `onCompromised` given here sets the lock's `lost` instead, +// and the backup holding the lock stops before it starts another file (see +// `runLockedBackup`). Releasing a lost lock leaves `backup.lock` alone, since +// it may be another backup's. export const lockBackupDirectory = async ( downloadDirectory: string, -): Promise<() => Promise<void>> => { +): Promise<BackupLock> => { mkdirSync(downloadDirectory, { recursive: true }); + let release: () => Promise<void>; + const lock: BackupLock = { + release: async () => { + if (lock.lost === undefined) await release(); + }, + }; try { - return await lockfile.lock(downloadDirectory, { + release = await lockfile.lock(downloadDirectory, { lockfilePath: join(downloadDirectory, "backup.lock"), + onCompromised: (err) => { + lock.lost = new Error( + `lost the lock on ${downloadDirectory}: ${err.message}`, + ); + }, }); } catch (err) { if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err; @@ -905,10 +944,11 @@ export const lockBackupDirectory = async ( { code: "ELOCKED" }, ); } + return lock; }; // Runs the backup holding the lock, which it takes and releases itself unless -// the caller already holds it (`opts.lockHeld`). +// the caller passes the lock it took (`opts.lock`). export const runBackup = async ( lib: BackupLibrary, opts: BackupOptions, @@ -920,13 +960,13 @@ export const runBackup = async ( "open the library with one)", ); } - if (opts.lockHeld) { - return runLockedBackup(lib, opts, downloadDirectory); + if (opts.lock) { + return runLockedBackup(lib, opts, downloadDirectory, opts.lock); } - const release = await lockBackupDirectory(downloadDirectory); + const lock = await lockBackupDirectory(downloadDirectory); try { - return await runLockedBackup(lib, opts, downloadDirectory); + return await runLockedBackup(lib, opts, downloadDirectory, lock); } finally { - await release(); + await lock.release(); } }; diff --git a/src/cli-commands.ts b/src/cli-commands.ts index 076d1ef..7a0c505 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -20,7 +20,7 @@ import { type ClientSnapshot, type LoginOptions, } from "./client.js"; -import { lockBackupDirectory } from "./backup.js"; +import { lockBackupDirectory, type BackupLock } from "./backup.js"; import { init } from "./crypto/index.js"; import { defaultCacheDirectory, @@ -416,9 +416,9 @@ export const backupCommand = async ( ctx.stderr.write("Starting backup...\n"); // The lock is taken before the library opens and starts its refresh, so a // run refused while another backup of `dir` runs sends no request. - let release: () => Promise<void>; + let lock: BackupLock; try { - release = await lockBackupDirectory(dir); + lock = await lockBackupDirectory(dir); } catch (err) { if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err; ctx.stderr.write(`quak: ${(err as Error).message}\n`); @@ -440,7 +440,7 @@ export const backupCommand = async ( try { const result = await lib.backup({ downloadDirectory: dir, - lockHeld: true, + lock, verify: opts.verify, onProgress: (msg) => { if (!opts.json) ctx.stderr.write(msg + "\n"); @@ -477,7 +477,7 @@ export const backupCommand = async ( await lib.close(); } } finally { - await release(); + await lock.release(); } }; diff --git a/src/index.ts b/src/index.ts index 1328893..686dbef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -69,6 +69,7 @@ export { type EnsureEvent, lockBackupDirectory, runBackup, + type BackupLock, type BackupOptions, type BackupResult, type BackupError, diff --git a/src/library/index.ts b/src/library/index.ts index 443f4d4..eb36410 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -96,6 +96,7 @@ import { runBackup, type BackupOptions, type BackupResult } from "../backup.js"; export { lockBackupDirectory, runBackup, + type BackupLock, type BackupOptions, type BackupResult, type BackupError, @@ -570,9 +571,10 @@ export class Library { // Back up every in-scope file to `opts.downloadDirectory`, or else the // library's, each original at its save path, with a durable failure - // ledger (issue #51). Takes the lock in that directory first, unless - // `opts.lockHeld` says the caller holds it, failing at once while another - // backup of it runs (see `runBackup`). Waits for a + // ledger (issue #51). Takes the lock in that directory first, unless the + // caller passes the lock it took as `opts.lock`, failing at once while + // another backup of it runs, and before it starts another file once the + // lock is lost (see `lockBackupDirectory`). Waits for a // completed refresh, as `fresh()` does, joining one already running, and // rejects before touching any file but the lock when it fails. Then puts // pending originals at their save paths as `Photo.download()` does (and diff --git a/test/cli/backup-end-to-end.test.ts b/test/cli/backup-end-to-end.test.ts index 123c00a..886a1fc 100644 --- a/test/cli/backup-end-to-end.test.ts +++ b/test/cli/backup-end-to-end.test.ts @@ -9,7 +9,8 @@ * data this file encrypts with libsodium, as Ente's clients encrypt it. So * quak's own code fetches each original, decrypts it, checks it against the * hash its uploader recorded and unpacks a live photo, and quak's own retries - * repeat a failed request. + * repeat a failed request. Two tests run `lib.backup()` instead, as a program + * using quak as a library does. * * The account, user 42, has three albums: * @@ -24,6 +25,7 @@ import { existsSync, lstatSync, + mkdirSync, mkdtempSync, readdirSync, readFileSync, @@ -36,7 +38,16 @@ import { join } from "node:path"; import { Writable } from "node:stream"; import { gzipSync } from "node:zlib"; import sodium from "libsodium-wrappers-sumo"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; import { backupCommand, @@ -47,6 +58,7 @@ import { run } from "../../src/cli-run.js"; import { loadSession } from "../../src/cli-session.js"; import { Client } from "../../src/client.js"; import { init, toBase64 } from "../../src/crypto/index.js"; +import { Library, lockBackupDirectory } from "../../src/library/index.js"; import { UNATTENDED_RETRY_OPTIONS } from "../../src/retry.js"; import { buildAccount, @@ -633,3 +645,188 @@ describe("quak backup", () => { expect(stderr).toContain(" Verified: 2\n Mismatched: 1\n"); }); }); + +// `backup.lock` is a directory that proper-lockfile touches every 5 seconds, +// from a timer, while the backup runs. Each time, it first looks at the lock +// on disk. The lock is lost when it is gone, or when another backup has made +// its own in its place, as one does with a lock nobody touched for 10 +// seconds. Another backup may then be writing to the directory, so the backup +// stops before it starts another file. +// +// The fake server holds one file's download open until the test lets it go, +// so the lock can be lost while the backup runs: beach.jpg's, the first file +// backed up, unless the test names another. Only `setTimeout` and +// `clearTimeout` are faked, so a test can fire proper-lockfile's timer without +// waiting 5 seconds. +describe("a backup whose lock is lost", () => { + let dir: string; + let lockPath: string; + let downloads: number[]; + let heldFile: number; + let heldRequested: Promise<void>; + let releaseHeld: () => void; + let fetch: typeof globalThis.fetch; + + beforeEach(() => { + dir = mkdtempSync(join(root, "backup-")); + lockPath = join(dir, "backup.lock"); + downloads = []; + heldFile = BEACH.id; + let requested!: () => void; + heldRequested = new Promise((resolve) => { + requested = resolve; + }); + const released = new Promise<void>((resolve) => { + releaseHeld = resolve; + }); + const recording = recordingDownloads(downloads); + fetch = async (input, init) => { + if (downloadOf(input) === heldFile) { + requested(); + await released; + } + return recording(input, init); + }; + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // Fire proper-lockfile's timer, and wait for the look at the lock on disk + // that it starts to finish: Node lists a file system call in progress as + // an `FSReqCallback`. + const fireLockTimer = async (): Promise<void> => { + vi.advanceTimersByTime(5_000); + while (process.getActiveResourcesInfo().includes("FSReqCallback")) { + await new Promise((resolve) => setImmediate(resolve)); + } + }; + + // beach.jpg, whose download was under way, is stored; no other file is + // started. `quak backup` prints why as one line, with no stack trace, and + // exits 1. + it("stops quak backup before its next file when backup.lock is removed, and exits 1", async () => { + const result = backup(dir, {}, fetch); + await heldRequested; + rmSync(lockPath, { recursive: true }); + await fireLockTimer(); + releaseHeld(); + const { code, stderr } = await result; + + expect(code).toBe(1); + expect(stderr).toBe( + "Starting backup...\n" + + "Refreshing library...\n" + + "Fetching original beach.jpg (100)...\n" + + `quak: lost the lock on ${dir}: ENOENT: no such file or directory, stat '${lockPath}'\n`, + ); + expect(downloads).toEqual([BEACH.id]); + expect(readFileSync(join(dir, DAY, "2024-03-01.100.jpg"))).toEqual( + Buffer.from(JPEG_WITH_EXIF), + ); + }); + + // Here the lock is lost while toast.mp4, the last original, downloads. + // All three originals are stored, beside what the backup wrote before it + // started them, but no file's JSON and no album is written. + it("stops quak backup before any file's JSON when the lock is lost during the last original", async () => { + heldFile = TOAST_VIDEO.id; + const result = backup(dir, {}, fetch); + await heldRequested; + rmSync(lockPath, { recursive: true }); + await fireLockTimer(); + releaseHeld(); + const { code, stderr } = await result; + + expect(code).toBe(1); + expect(stderr).toContain(`quak: lost the lock on ${dir}: `); + expect(listTree(dir)).toEqual([ + "2024", + "2024/2024-03", + "2024/2024-03/2024-03-01", + "2024/2024-03/2024-03-01/2024-03-01.100.jpg", + "2024/2024-03/2024-03-01/2024-03-01.200.mp4", + "2024/2024-03/2024-03-01/2024-03-01.300.heic", + "2024/2024-03/2024-03-01/2024-03-01.300.livephoto.json", + "2024/2024-03/2024-03-01/2024-03-01.300.mov", + "account.json", + "collections", + ]); + }); + + // Nothing touches the lock but quak, which finds it as it left it each + // time it looks, so the backup runs to the end and removes its lock. + it("lets quak backup run to the end while its lock stays", async () => { + const result = backup(dir, {}, fetch); + await heldRequested; + await fireLockTimer(); + await fireLockTimer(); + releaseHeld(); + const { code } = await result; + + expect(code).toBe(0); + expect(downloads.sort()).toEqual([100, 200, 300]); + expect(existsSync(lockPath)).toBe(false); + }); + + // The library a program using quak opens to back `dir` up. + const openLibrary = () => + Library.open({ + client: loadSession(join(sessionDir, "session.json"), { fetch })!, + cacheDirectory: `${dir}-cache`, + downloadDirectory: dir, + refreshIntervalSeconds: Infinity, + precacheThumbnails: false, + precacheOriginals: false, + }); + + // A program using quak as a library sees the same: `lib.backup()` rejects + // with the reason. Here another backup has made its own lock in place of + // quak's, and that lock is left where it is. + it("rejects lib.backup() when another backup has taken the lock over", async () => { + const lib = await openLibrary(); + try { + const result = lib.backup(); + await heldRequested; + rmSync(lockPath, { recursive: true }); + mkdirSync(lockPath); + await fireLockTimer(); + releaseHeld(); + + await expect(result).rejects.toThrow( + `lost the lock on ${dir}: Unable to update lock within the stale threshold`, + ); + expect(downloads).toEqual([BEACH.id]); + expect(existsSync(lockPath)).toBe(true); + } finally { + await lib.close(); + } + }); + + // A program can take the lock before it opens its library, as `quak + // backup` does: `lockBackupDirectory` returns the lock, which the program + // passes to `lib.backup()` and releases once the backup ends. The backup + // checks it as it checks a lock it took, and the lock's `lost` says why it + // was lost. + it("rejects lib.backup() given its caller's lock once that lock is lost", async () => { + const lock = await lockBackupDirectory(dir); + const lib = await openLibrary(); + try { + const result = lib.backup({ lock }); + await heldRequested; + rmSync(lockPath, { recursive: true }); + await fireLockTimer(); + releaseHeld(); + + const lost = `lost the lock on ${dir}: ENOENT: no such file or directory, stat '${lockPath}'`; + await expect(result).rejects.toThrow(lost); + expect(lock.lost?.message).toBe(lost); + expect(downloads).toEqual([BEACH.id]); + } finally { + await lib.close(); + await lock.release(); + } + }); +}); From 22ee2db479264aeadfba20817eea6f2782df8279 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 14 Oct 2026 08:07:48 +0200 Subject: [PATCH 28/39] TODO.md's Next Step names the open issues, not a closed one (closes #194) Next Step said the cache design, issue 36, waits on sneak's review. That issue closed on 2026-10-03. Next Step now names what is open: the backup concurrency issue, built in pull request 192 and waiting on review, and the live-account check that only sneak can run. Model: opus-5-5 --- TODO.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index 5f555ea..c231724 100644 --- a/TODO.md +++ b/TODO.md @@ -17,14 +17,22 @@ pre-1.0 # Next Step -None: no implementation work is open. The cache design, -https://git.eeqj.de/sneak/quak/issues/36, waits on sneak's review. +`quak backup` downloading one original at a time, +https://git.eeqj.de/sneak/quak/issues/182, is fixed in +https://git.eeqj.de/sneak/quak/pulls/192, which waits on review. No other +implementation work is open. The check of quak against a live Ente account, +https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak has, so +it waits on him. Tagging and releases are decided by sneak alone, and happen only when he declares one. # Completed Steps +- 2026-10-14: Next Step said the cache design, issue 36, waits on sneak's + review, though that issue closed on 2026-10-03 (issue 194). It now names the + issues open on the tracker. + - 2026-10-14: A backup whose lock is lost no longer dies with a stack trace (issue 184). `lockBackupDirectory` gives proper-lockfile an `onCompromised` that records why the lock was lost instead of throwing from its timer, and From 3412e42af598b2b2d988dee2d8348f6b3f3223bd Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 14 Oct 2026 09:06:59 +0200 Subject: [PATCH 29/39] quak backup works on up to the content pool's limit of originals at once (closes #182) The backup awaited each original before starting the next, so it used one of the content pool's five slots. lib.backup(), and so quak backup, now runs as many workers as the content pool's limit (5 by default, or the contentConcurrency of the pools the library was opened with), each taking the next file from one shared list, so the limit covers the --verify hashing too. Once a worker has thrown or the lock is lost, no worker takes another file; the backup waits for the files under way and checks its lock before the ML data fetch. runBackup rejects a contentConcurrency that is not a positive integer before it takes the lock. Failed files are listed in the order they failed. Model: opus-5-5 --- README.md | 11 +- TODO.md | 20 +- src/backup.ts | 167 +++++--- src/library/index.ts | 1 + test/cli/backup-concurrency.test.ts | 608 ++++++++++++++++++++++++++++ test/cli/backup-end-to-end.test.ts | 17 +- test/cli/backup.test.ts | 22 + 7 files changed, 763 insertions(+), 83 deletions(-) create mode 100644 test/cli/backup-concurrency.test.ts diff --git a/README.md b/README.md index bc74537..12fd670 100644 --- a/README.md +++ b/README.md @@ -779,9 +779,9 @@ A running backup loses its lock when someone removes `backup.lock`, or when another backup takes it over because the running one left it untouched for 10 seconds, as a stopped or stalled process does. The backup finds this out the next time it goes to touch the lock, which it does every 5 seconds, and then -stops before it starts another file. The original it is downloading at that -moment is still stored. It leaves `failures.json` and `backup.lock` as they are, -since another backup may be using them. `quak backup` prints +stops before it starts another file. The originals it is downloading at that +moment are still stored. It leaves `failures.json` and `backup.lock` as they +are, since another backup may be using them. `quak backup` prints `quak: lost the lock on <dir>: <reason>` as one line and exits 1, and `lib.backup()` rejects with `lost the lock on <dir>: <reason>`. @@ -1096,6 +1096,11 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange` Each pool orders on-demand work ahead of background/precache work and dedups in-flight fetches by key, and an idle pool never lends its slots to a busy one. +`lib.backup()`, and so `quak backup`, works on up to the content pool's limit of +originals at once, 5 unless the library was opened with other `pools`. The limit +covers everything it does for one original, hashing it for `verify` included, so +however large the account is, no more originals than that are open at once. + ### On-disk cache layout Under `cacheDirectory`: diff --git a/TODO.md b/TODO.md index c231724..1aa7f9b 100644 --- a/TODO.md +++ b/TODO.md @@ -17,18 +17,26 @@ pre-1.0 # Next Step -`quak backup` downloading one original at a time, -https://git.eeqj.de/sneak/quak/issues/182, is fixed in -https://git.eeqj.de/sneak/quak/pulls/192, which waits on review. No other -implementation work is open. The check of quak against a live Ente account, -https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak has, so -it waits on him. +None: no implementation work is open. The check of quak against a live Ente +account, https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak +has, so it waits on him. Tagging and releases are decided by sneak alone, and happen only when he declares one. # Completed Steps +- 2026-10-14: `lib.backup()`, and so `quak backup`, works on up to the content + pool's limit of originals at once, 5 by default, instead of one at a time + (issue 182). That many workers take files from one shared list, each doing all + the work for one original, its `--verify` hashing included, so no more + originals than that are open at once. `BackupLibrary` says the limit as + `contentConcurrency`. A test holds each download at the fake Ente server to + count those in flight. Once a worker has thrown, as one does when `onProgress` + throws, or the lock is lost, no worker takes another file; the backup fails + once the originals under way are in place, and a lost lock stops it before it + fetches ML data. + - 2026-10-14: Next Step said the cache design, issue 36, waits on sneak's review, though that issue closed on 2026-10-03 (issue 194). It now names the issues open on the tracker. diff --git a/src/backup.ts b/src/backup.ts index bd76d2f..488ff30 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -6,9 +6,9 @@ // file. It waits for a completed refresh of the library (a failed one fails the // backup before any file is touched), then, for every file in scope, puts its // original at its save path under `downloadDirectory`, as `Photo.download()` -// does, waits for an ML data fetch, and rebuilds the derived views (per-file -// sidecars, per-collection symlink trees, per-collection JSON) from the model. -// The on-disk layout: +// does, up to the content pool's limit of them at once, waits for an ML data +// fetch, and rebuilds the derived views (per-file sidecars, per-collection +// symlink trees, per-collection JSON) from the model. The on-disk layout: // // <downloadDirectory>/ // YYYY/YYYY-MM/YYYY-MM-DD/ @@ -181,6 +181,9 @@ export interface BackupLibrary { destination: string, ): Promise<{ path: string; videoPath?: string }>; thumbnail(fileID: number): Promise<{ path: string }>; + // How many originals the backup works on at once: the limit of the + // library's content pool. + contentConcurrency: number; // Wait for an ML data fetch to complete, joining one already running or // starting one. Rejects with the reason when it fails; resolves at once // when the library cannot fetch ML data. @@ -654,75 +657,31 @@ const runLockedBackup = async ( // present original is hashed first, and one that does not match the hash // its metadata records is removed and fetched like a missing one. One // that cannot be read is recorded as failed and left where it is. - if (includeOriginals) { - for (const [fileID, file] of distinct) { - if (lock.lost) throw lock.lost; - let stored = storedAtSavePath(downloadDirectory, file); - let mismatch = false; - if (stored !== undefined && verify) { - try { - if (file.metadata.hash === undefined) { - unchecked++; - } else if ( - (await storedHash(stored)) === file.metadata.hash - ) { - verified++; - } else { - log( - `MISMATCH original ${file.metadata.title} (${fileID}): its bytes do not match its content hash`, - ); - mismatched++; - mismatch = true; - rmSync(stored.path); - if (stored.videoPath !== undefined) { - rmSync(stored.videoPath); - } - stored = undefined; - } - } catch (err) { - log( - `FAILED verifying original ${file.metadata.title}: ${errorMessage(err)}`, - ); - recordFailure( - file.id, - file.metadata.title, - collectionName.get(file.collectionID) ?? "", - err, - ); - continue; - } - } - if (stored !== undefined) { - skipped++; - continue; - } + // `storeOriginal` does this for one original, and records its failure. + const storeOriginal = async (file: EnteFile): Promise<void> => { + let stored = storedAtSavePath(downloadDirectory, file); + let mismatch = false; + if (stored !== undefined && verify) { try { - log(`Fetching original ${file.metadata.title} (${fileID})...`); - // A fetched original is written straight to its save path (a - // live photo beside it); only one that was already cached - // elsewhere is copied. - const placed = await placeOriginal( - downloadDirectory, - file, - (dest) => lib.original(fileID, dest), - ); - storedThisRun.add(fileID); - // A copy from the cache is not checked as a download is and can - // hold the same bad bytes, so what is put back after a - // mismatch is hashed too. A bad copy stays where it is and the - // file fails. - if ( - mismatch && - (await storedHash(placed)) !== file.metadata.hash - ) { - throw new Error( - "the original put back does not match its content hash either", + if (file.metadata.hash === undefined) { + unchecked++; + } else if ((await storedHash(stored)) === file.metadata.hash) { + verified++; + } else { + log( + `MISMATCH original ${file.metadata.title} (${file.id}): its bytes do not match its content hash`, ); + mismatched++; + mismatch = true; + rmSync(stored.path); + if (stored.videoPath !== undefined) { + rmSync(stored.videoPath); + } + stored = undefined; } - downloaded++; } catch (err) { log( - `FAILED original ${file.metadata.title}: ${errorMessage(err)}`, + `FAILED verifying original ${file.metadata.title}: ${errorMessage(err)}`, ); recordFailure( file.id, @@ -730,8 +689,74 @@ const runLockedBackup = async ( collectionName.get(file.collectionID) ?? "", err, ); + return; } } + if (stored !== undefined) { + skipped++; + return; + } + try { + log(`Fetching original ${file.metadata.title} (${file.id})...`); + // A fetched original is written straight to its save path (a + // live photo beside it); only one that was already cached + // elsewhere is copied. + const placed = await placeOriginal( + downloadDirectory, + file, + (dest) => lib.original(file.id, dest), + ); + storedThisRun.add(file.id); + // A copy from the cache is not checked as a download is and can + // hold the same bad bytes, so what is put back after a mismatch + // is hashed too. A bad copy stays where it is and the file fails. + if (mismatch && (await storedHash(placed)) !== file.metadata.hash) { + throw new Error( + "the original put back does not match its content hash either", + ); + } + downloaded++; + } catch (err) { + log(`FAILED original ${file.metadata.title}: ${errorMessage(err)}`); + recordFailure( + file.id, + file.metadata.title, + collectionName.get(file.collectionID) ?? "", + err, + ); + } + }; + + // Up to the content pool's limit of originals are worked on at once, + // their `verify` hashing included, so however large the account is, no + // more than that many are open. Each worker takes the next file from the + // iterator they all share until none is left. Once a worker has thrown, + // as one does when `onProgress` throws, or the lock is lost, no worker + // takes another file. The backup fails only once every worker has + // stopped, so none writes into the backup after it has ended. It checks + // the lock then too, so it does not go on to the thumbnails or the ML + // data fetch without it. + if (includeOriginals) { + const pending = distinct.values(); + let workerThrew = false; + const worker = async (): Promise<void> => { + for (const file of pending) { + if (workerThrew || lock.lost) return; + try { + await storeOriginal(file); + } catch (err) { + workerThrew = true; + throw err; + } + } + }; + const workers = await Promise.allSettled( + Array.from({ length: lib.contentConcurrency }, worker), + ); + for (const w of workers) { + if (w.status === "rejected") throw w.reason; + } + if (lock.lost) throw lock.lost; } if (includeThumbnails) { @@ -960,6 +985,14 @@ export const runBackup = async ( "open the library with one)", ); } + // As `BoundedPool` does. A limit below 1, or one that is not a number, + // would back up no original and end with no error. + const { contentConcurrency } = lib; + if (!Number.isInteger(contentConcurrency) || contentConcurrency < 1) { + throw new RangeError( + `contentConcurrency must be a positive integer, got ${contentConcurrency}`, + ); + } if (opts.lock) { return runLockedBackup(lib, opts, downloadDirectory, opts.lock); } diff --git a/src/library/index.ts b/src/library/index.ts index eb36410..95d8e1d 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -607,6 +607,7 @@ export class Library { original: (fileID, destination) => cache!.backupOriginal(fileID, destination), thumbnail: (fileID) => cache!.thumbnail(fileID), + contentConcurrency: this.pools.content.concurrency, fetchMLData: () => this.fetchMLDataNow(), mlData: (fileID) => this.mldata.forFile({ fileID }), }, diff --git a/test/cli/backup-concurrency.test.ts b/test/cli/backup-concurrency.test.ts new file mode 100644 index 0000000..f3c476c --- /dev/null +++ b/test/cli/backup-concurrency.test.ts @@ -0,0 +1,608 @@ +/** + * How many originals `lib.backup()` works on at once. + * + * The backup puts up to the content pool's limit of originals at their save + * paths at the same time: 5 for a library opened with the default + * `RequestPools`, as `quak backup` opens it, or the `contentConcurrency` of the + * pools a library was opened with. Everything the backup does for one original + * counts against that limit, hashing it for `verify` included, so however large + * the account is, the backup has no more than that many originals open at once. + * + * As in `backup-end-to-end.test.ts`, the real `Client` and `Library` run + * against `buildMetaFetch` from `test/ente-server.ts`, a fake of the Ente HTTP + * API serving data this file encrypts with libsodium. Here a test can also hold + * each original's download until it lets it go, to see how many the backup has + * in flight at once. + * + * The account, user 42, has one album of its own, Holiday, of eight photos, + * all taken at noon on 1 March 2024, each with an original of its own. + */ + +import { + existsSync, + lstatSync, + mkdtempSync, + readdirSync, + readFileSync, + readlinkSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import sodium from "libsodium-wrappers-sumo"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import type { BackupOptions } from "../../src/backup.js"; +import { Client, type ClientSnapshot } from "../../src/client.js"; +import { init, toBase64 } from "../../src/crypto/index.js"; +import { Library } from "../../src/library/index.js"; +import { RequestPools } from "../../src/library/pools.js"; +import { + buildAccount, + buildMetaFetch, + encryptSecretbox, + encryptStreamBlob, +} from "../ente-server.js"; +import { blake2b } from "../live-photo.js"; + +const EMAIL = "backup@example.com"; +const PASSWORD = "backup-password"; +// The fake server logs every account in as user 42. +const USER_ID = 42; + +// Noon on 1 March 2024 in the time zone of the machine running the test, in +// microseconds, as Ente records times. +const TAKEN = new Date(2024, 2, 1, 12).getTime() * 1000; + +// The eight photos' IDs. Photo `id` is titled `photo-<id>.jpg`, and its +// original is a few bytes naming it. +const PHOTO_IDS = [101, 102, 103, 104, 105, 106, 107, 108]; +const originalOf = (id: number): Uint8Array => + new TextEncoder().encode(`the photo ${id}`); + +// Where a backup to `dir` saves photo `id`'s original. +const savedAt = (dir: string, id: number): string => + join(dir, "2024/2024-03/2024-03-01", `2024-03-01.${id}.jpg`); + +// The fake server, the session it logs the account in with, and the +// directory every backup below is made in. +let server: typeof globalThis.fetch; +let session: ClientSnapshot; +let root: string; + +// The fake server, holding the account encrypted as Ente's clients upload it. +const buildServer = async () => { + const account = await buildAccount(PASSWORD); + + // The album as `/collections/v2` lists it: its key encrypted with the + // account's master key, its name with its own key. + const albumKey = sodium.crypto_secretbox_keygen(); + const encryptedAlbumKey = encryptSecretbox(albumKey, account.masterKey); + const encryptedName = encryptSecretbox( + new TextEncoder().encode("Holiday"), + albumKey, + ); + const album = { + id: 10, + owner: { id: USER_ID }, + encryptedKey: toBase64(encryptedAlbumKey.ciphertext), + keyDecryptionNonce: toBase64(encryptedAlbumKey.nonce), + encryptedName: toBase64(encryptedName.ciphertext), + nameDecryptionNonce: toBase64(encryptedName.nonce), + type: "album", + updationTime: TAKEN, + }; + + // Each photo as the album's `/collections/v2/diff` lists it: its key + // encrypted with the album's key, and its metadata, holding the hash of + // its original, encrypted with its own key. Its original, encrypted with + // its own key too, is what the files CDN serves for it. + const ciphertexts: Record<number, Uint8Array> = {}; + const photos = PHOTO_IDS.map((id) => { + const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const original = encryptStreamBlob(originalOf(id), key); + ciphertexts[id] = original.ciphertext; + const encryptedKey = encryptSecretbox(key, albumKey); + const metadata = encryptStreamBlob( + new TextEncoder().encode( + JSON.stringify({ + title: `photo-${id}.jpg`, + fileType: 0, + hash: blake2b(originalOf(id)), + creationTime: TAKEN, + modificationTime: TAKEN, + }), + ), + key, + ); + return { + id, + collectionID: 10, + ownerID: USER_ID, + encryptedKey: toBase64(encryptedKey.ciphertext), + keyDecryptionNonce: toBase64(encryptedKey.nonce), + metadata: { + encryptedData: toBase64(metadata.ciphertext), + decryptionHeader: toBase64(metadata.header), + }, + file: { decryptionHeader: toBase64(original.header) }, + thumbnail: { + decryptionHeader: toBase64(sodium.randombytes_buf(24)), + }, + updationTime: TAKEN, + }; + }); + + return buildMetaFetch({ + verifier: account.verifier, + srpAttributes: account.srpAttributes, + keyAttributes: account.keyAttributes, + encryptedToken: account.encryptedToken, + collections: [album], + filesByCollection: { 10: photos }, + encryptedMLData: {}, + fileCiphertexts: ciphertexts, + }); +}; + +beforeAll(async () => { + await init(); + await sodium.ready; + root = mkdtempSync(join(tmpdir(), "quak-backup-concurrency-")); + server = await buildServer(); + const client = await Client.login({ + email: EMAIL, + password: PASSWORD, + apiOptions: { fetch: server }, + }); + session = client.toJSON(); +}); + +afterAll(() => { + rmSync(root, { recursive: true, force: true }); +}); + +// The ID of the photo whose original a request downloads, or undefined for any +// other request. quak downloads an original from +// `https://files.ente.io/?fileID=<id>`. +const downloadOf = (input: RequestInfo | URL): number | undefined => { + const url = new URL(String(input)); + return url.hostname === "files.ente.io" + ? Number(url.searchParams.get("fileID")) + : undefined; +}; + +// The fake server, except that it answers 404 to every download of photo +// `failing`'s original. +const failingFor = + (failing: number): typeof globalThis.fetch => + async (input, init) => + downloadOf(input) === failing + ? new Response("not found", { status: 404 }) + : server(input, init); + +// The fake server, holding each download of an original until the test lets +// it go. `held` lists the downloads waiting, the first to arrive first; +// `most()` is the largest number that ever waited at once. A held download of +// photo `failing`'s original is answered 404 once it is let go. +const holdingServer = (failing?: number) => { + const held: { id: number; release: () => void }[] = []; + let most = 0; + const fetch: typeof globalThis.fetch = async (input, init) => { + const id = downloadOf(input); + if (id !== undefined) { + await new Promise<void>((release) => { + held.push({ id, release }); + most = Math.max(most, held.length); + }); + if (id === failing) { + return new Response("not found", { status: 404 }); + } + } + return server(input, init); + }; + return { fetch, held, most: () => most }; +}; + +// Let the held downloads go one at a time, the first to arrive first, each +// once `limit` of them wait, or all the downloads still to come when there +// are fewer. Each of the eight photos is downloaded once. With a backup that +// downloads fewer than `limit` at once, this fails waiting for them. Each wait +// allows five seconds, for a loaded machine's disk to sync the files written. +const releaseOneAtATime = async ( + held: { release: () => void }[], + limit: number, +): Promise<void> => { + for (let left = PHOTO_IDS.length; left > 0; left--) { + await vi.waitFor( + () => expect(held).toHaveLength(Math.min(limit, left)), + { interval: 5, timeout: 5000 }, + ); + held.shift()!.release(); + } +}; + +// A library of the account opened as `quak backup` opens one, with no +// precache and no background refresh, sending its requests through `fetch`. +// It backs up to `dir` and keeps its cache beside it. Its request pools are +// `pools`, or by default the library's own. +const openLibrary = ( + dir: string, + fetch: typeof globalThis.fetch, + pools?: RequestPools, +): Promise<Library> => + Library.open({ + client: Client.fromJSON(session, { fetch }), + downloadDirectory: dir, + cacheDirectory: `${dir}-cache`, + refreshIntervalSeconds: Infinity, + precacheThumbnails: false, + precacheOriginals: false, + pools, + }); + +// Every entry under `dir`, relative to it, sorted, a symlink as +// `name -> target`. +const listTree = (dir: string): string[] => + readdirSync(dir, { recursive: true }) + .sort() + .map((name) => { + const path = join(dir, name); + return lstatSync(path).isSymbolicLink() + ? `${name} -> ${readlinkSync(path)}` + : name; + }); + +const expectStored = (dir: string, ids: number[]): void => { + for (const id of ids) { + expect(readFileSync(savedAt(dir, id))).toEqual( + Buffer.from(originalOf(id)), + ); + } +}; + +describe("lib.backup()", () => { + // A library opened with the default pools, as `quak backup` opens one, + // downloads up to five originals at once. As soon as one finishes, the + // backup starts on the next photo, so five stay in flight until fewer + // than five are left. + it("downloads five originals at once, and never more, with the default pools", async () => { + const dir = mkdtempSync(join(root, "backup-")); + const holding = holdingServer(); + const lib = await openLibrary(dir, holding.fetch); + try { + const backup = lib.backup(); + await releaseOneAtATime(holding.held, 5); + const result = await backup; + + expect(holding.most()).toBe(5); + expect(result).toMatchObject({ + totalFiles: 8, + downloaded: 8, + failed: 0, + }); + expectStored(dir, PHOTO_IDS); + } finally { + await lib.close(); + } + }); + + // The limit is the content pool's, so a library opened with pools of + // another `contentConcurrency` backs up that many at once. + it("downloads as many originals at once as the content pool of the pools it was opened with allows", async () => { + const dir = mkdtempSync(join(root, "backup-")); + const holding = holdingServer(); + const lib = await openLibrary( + dir, + holding.fetch, + new RequestPools({ contentConcurrency: 2 }), + ); + try { + const backup = lib.backup(); + await releaseOneAtATime(holding.held, 2); + const result = await backup; + + expect(holding.most()).toBe(2); + expect(result).toMatchObject({ downloaded: 8, failed: 0 }); + expectStored(dir, PHOTO_IDS); + } finally { + await lib.close(); + } + }); + + // With `verify`, hashing an original already stored is part of the work + // on it, and counts against the limit too: a large backup never has more + // than five originals open for hashing. Here every stored original has + // changed on disk, so each is hashed, found not to match, and downloaded + // again. While five downloads wait, the backup has hashed only those five + // photos' originals; it opens the next one once a download finishes. + it("with verify, hashes no more originals ahead of their downloads than the limit", async () => { + const dir = mkdtempSync(join(root, "backup-")); + const first = await openLibrary(dir, server); + try { + await first.backup(); + } finally { + await first.close(); + } + for (const id of PHOTO_IDS) { + writeFileSync(savedAt(dir, id), "not the photo any more"); + } + + const holding = holdingServer(); + const lib = await openLibrary(dir, holding.fetch); + const lines: string[] = []; + const mismatches = () => + lines.filter((line) => line.startsWith("MISMATCH")); + try { + const backup = lib.backup({ + verify: true, + onProgress: (line) => lines.push(line), + }); + await vi.waitFor(() => expect(holding.held).toHaveLength(5), { + interval: 5, + timeout: 5000, + }); + expect(mismatches()).toHaveLength(5); + + await releaseOneAtATime(holding.held, 5); + const result = await backup; + + expect(holding.most()).toBe(5); + expect(mismatches()).toHaveLength(8); + expect(result).toMatchObject({ + downloaded: 8, + verified: 0, + mismatched: 8, + failed: 0, + }); + expectStored(dir, PHOTO_IDS); + } finally { + await lib.close(); + } + }); + + // The server answers 404 for photo 103's original. A 404 is not retried, + // so that download fails at once, while other downloads are still in + // flight. The backup records the failure and goes on: the downloads + // beside it finish, the photos after it are downloaded, and only photo + // 103 is missing from the backup and in failures.json. + it("goes on with the other originals when one fails", async () => { + const dir = mkdtempSync(join(root, "backup-")); + const holding = holdingServer(103); + const lib = await openLibrary(dir, holding.fetch); + try { + const backup = lib.backup(); + await releaseOneAtATime(holding.held, 5); + const result = await backup; + + expect(result).toMatchObject({ + totalFiles: 8, + downloaded: 7, + failed: 1, + }); + expect(result.errors.map((e) => e.fileID)).toEqual([103]); + expectStored( + dir, + PHOTO_IDS.filter((id) => id !== 103), + ); + expect(existsSync(savedAt(dir, 103))).toBe(false); + const failures = JSON.parse( + readFileSync(join(dir, "failures.json"), "utf-8"), + ); + expect(Object.keys(failures.files)).toEqual(["103"]); + } finally { + await lib.close(); + } + }); + + // A throw from `onProgress` fails the backup: `lib.backup()` rejects with + // it. From then on the backup starts no further original. It finishes the + // ones it is working on, and rejects only once they are in place, so + // nothing is written into the backup after it has ended and removed its + // lock. Here the first five downloads wait. Photo 103's is let go first + // and answered 404, and the line reporting that failure throws while the + // other four wait. Only those five downloads are held: a backup that went + // on would store the other three photos. + it("when onProgress throws, starts no further original, and rejects once the originals it was working on are in place", async () => { + const dir = mkdtempSync(join(root, "backup-")); + const downloads: number[] = []; + const held: { id: number; release: () => void }[] = []; + const fetch: typeof globalThis.fetch = async (input, init) => { + const id = downloadOf(input); + if (id !== undefined) { + downloads.push(id); + if (downloads.length <= 5) { + await new Promise<void>((release) => + held.push({ id, release }), + ); + } + if (id === 103) { + return new Response("not found", { status: 404 }); + } + } + return server(input, init); + }; + const lib = await openLibrary(dir, fetch); + const lines: string[] = []; + try { + const backup = lib.backup({ + onProgress: (line) => { + lines.push(line); + if (line.startsWith("FAILED")) { + throw new Error("the progress output is closed"); + } + }, + }); + // The photos whose originals are in place, and whether the lock + // is there, at the moment `lib.backup()` rejects. + let storedWhenRejected: number[] = []; + let lockedWhenRejected = true; + const rejection = backup.catch((err: unknown) => { + storedWhenRejected = PHOTO_IDS.filter((id) => + existsSync(savedAt(dir, id)), + ); + lockedWhenRejected = existsSync(join(dir, "backup.lock")); + return err; + }); + + await vi.waitFor(() => expect(held).toHaveLength(5), { + interval: 5, + timeout: 5000, + }); + const at103 = held.findIndex((h) => h.id === 103); + held.splice(at103, 1)[0]!.release(); + await vi.waitFor( + () => + expect( + lines.some((line) => line.startsWith("FAILED")), + ).toBe(true), + { interval: 5, timeout: 5000 }, + ); + for (const download of held) download.release(); + + expect(await rejection).toEqual( + new Error("the progress output is closed"), + ); + expect(downloads.sort()).toEqual([101, 102, 103, 104, 105]); + expect(storedWhenRejected).toEqual([101, 102, 104, 105]); + expect(lockedWhenRejected).toBe(false); + } finally { + await lib.close(); + } + }); + + // Once the backup's lock is lost, another backup may be writing to the + // directory, so the backup starts no further original. It finishes the + // ones it is working on, and `lib.backup()` rejects once they are all in + // place. Here `backup.lock` is removed while the first five downloads + // wait, and proper-lockfile's timer finds it gone. As in + // `backup-end-to-end.test.ts`, only `setTimeout` and `clearTimeout` are + // faked, so the test can fire that timer without waiting 5 seconds. Only + // the first five downloads are held: a backup that went on would store + // the other three photos. + it("when its lock is lost, starts no further original, and rejects once the originals it was working on are in place", async () => { + const dir = mkdtempSync(join(root, "backup-")); + const lockPath = join(dir, "backup.lock"); + const downloads: number[] = []; + const held: (() => void)[] = []; + const fetch: typeof globalThis.fetch = async (input, init) => { + const id = downloadOf(input); + if (id !== undefined) { + downloads.push(id); + if (downloads.length <= 5) { + await new Promise<void>((release) => held.push(release)); + } + } + return server(input, init); + }; + const lib = await openLibrary(dir, fetch); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const backup = lib.backup(); + // The photos whose originals are in place at the moment + // `lib.backup()` rejects. + let storedWhenRejected: number[] = []; + const rejection = backup.catch((err: unknown) => { + storedWhenRejected = PHOTO_IDS.filter((id) => + existsSync(savedAt(dir, id)), + ); + return err; + }); + + await vi.waitFor(() => expect(held).toHaveLength(5), { + interval: 5, + timeout: 5000, + }); + rmSync(lockPath, { recursive: true }); + // Fire proper-lockfile's timer, and wait for the look at the lock + // on disk that it starts to finish: Node lists a file system call + // in progress as an `FSReqCallback`. + vi.advanceTimersByTime(5_000); + while (process.getActiveResourcesInfo().includes("FSReqCallback")) { + await new Promise((resolve) => setImmediate(resolve)); + } + for (const release of held) release(); + + expect(await rejection).toEqual( + new Error( + `lost the lock on ${dir}: ENOENT: no such file or directory, stat '${lockPath}'`, + ), + ); + expect(downloads.sort()).toEqual([101, 102, 103, 104, 105]); + expect(storedWhenRejected).toEqual([101, 102, 103, 104, 105]); + } finally { + vi.useRealTimers(); + await lib.close(); + } + }); + + // Working on several originals at once changes only how long a backup + // takes. The same two runs, with a content pool of one, which backs up + // one original at a time, and with the default pools, end the same way: + // the same result, the same progress lines (in another order), the same + // failures.json, and the same tree. The first run stores every photo but + // 103, whose download is answered 404. Then two stored originals change + // on disk, and a run with `verify` puts them back and tries 103 again. + it("ends as a backup of one original at a time does", async () => { + const twoRuns = async (pools: RequestPools) => { + const dir = mkdtempSync(join(root, "backup-")); + const lines: string[] = []; + const backup = async (opts: BackupOptions) => { + const lib = await openLibrary(dir, failingFor(103), pools); + try { + return await lib.backup({ + ...opts, + onProgress: (line) => lines.push(line), + }); + } finally { + await lib.close(); + } + }; + const first = await backup({}); + writeFileSync(savedAt(dir, 101), "not the photo any more"); + writeFileSync(savedAt(dir, 105), "not the photo any more"); + const second = await backup({ verify: true }); + + // Each entry of failures.json without the time it was last + // tried, which differs from run to run. + const { files } = JSON.parse( + readFileSync(join(dir, "failures.json"), "utf-8"), + ) as { files: Record<string, { lastTriedAt?: number }> }; + for (const entry of Object.values(files)) delete entry.lastTriedAt; + + return { + first, + second, + lines: lines.sort(), + failures: files, + tree: listTree(dir), + }; + }; + + const oneAtATime = await twoRuns( + new RequestPools({ contentConcurrency: 1 }), + ); + const fiveAtATime = await twoRuns(new RequestPools()); + + expect(oneAtATime.first).toMatchObject({ + totalFiles: 8, + downloaded: 7, + skipped: 0, + failed: 1, + }); + expect(oneAtATime.second).toMatchObject({ + totalFiles: 8, + downloaded: 2, + skipped: 5, + verified: 5, + mismatched: 2, + unchecked: 0, + failed: 1, + }); + expect(oneAtATime.failures).toEqual({ + "103": expect.objectContaining({ fileID: 103, attempts: 2 }), + }); + expect(fiveAtATime).toEqual(oneAtATime); + }); +}); diff --git a/test/cli/backup-end-to-end.test.ts b/test/cli/backup-end-to-end.test.ts index 886a1fc..65bf5b6 100644 --- a/test/cli/backup-end-to-end.test.ts +++ b/test/cli/backup-end-to-end.test.ts @@ -704,10 +704,11 @@ describe("a backup whose lock is lost", () => { } }; - // beach.jpg, whose download was under way, is stored; no other file is - // started. `quak backup` prints why as one line, with no stack trace, and - // exits 1. - it("stops quak backup before its next file when backup.lock is removed, and exits 1", async () => { + // The backup downloads up to five originals at once, so it has started + // all three when the lock is lost, and beach.jpg is stored. The backup + // then stops before it fetches ML data. `quak backup` prints why as one + // line, with no stack trace, and exits 1. + it("stops quak backup before it fetches ML data when backup.lock is removed, and exits 1", async () => { const result = backup(dir, {}, fetch); await heldRequested; rmSync(lockPath, { recursive: true }); @@ -720,9 +721,11 @@ describe("a backup whose lock is lost", () => { "Starting backup...\n" + "Refreshing library...\n" + "Fetching original beach.jpg (100)...\n" + + "Fetching original IMG_0002.HEIC (300)...\n" + + "Fetching original toast.mp4 (200)...\n" + `quak: lost the lock on ${dir}: ENOENT: no such file or directory, stat '${lockPath}'\n`, ); - expect(downloads).toEqual([BEACH.id]); + expect(downloads.sort()).toEqual([100, 200, 300]); expect(readFileSync(join(dir, DAY, "2024-03-01.100.jpg"))).toEqual( Buffer.from(JPEG_WITH_EXIF), ); @@ -798,7 +801,7 @@ describe("a backup whose lock is lost", () => { await expect(result).rejects.toThrow( `lost the lock on ${dir}: Unable to update lock within the stale threshold`, ); - expect(downloads).toEqual([BEACH.id]); + expect(downloads.sort()).toEqual([100, 200, 300]); expect(existsSync(lockPath)).toBe(true); } finally { await lib.close(); @@ -823,7 +826,7 @@ describe("a backup whose lock is lost", () => { const lost = `lost the lock on ${dir}: ENOENT: no such file or directory, stat '${lockPath}'`; await expect(result).rejects.toThrow(lost); expect(lock.lost?.message).toBe(lost); - expect(downloads).toEqual([BEACH.id]); + expect(downloads.sort()).toEqual([100, 200, 300]); } finally { await lib.close(); await lock.release(); diff --git a/test/cli/backup.test.ts b/test/cli/backup.test.ts index 065a2c5..1938a4b 100644 --- a/test/cli/backup.test.ts +++ b/test/cli/backup.test.ts @@ -909,6 +909,27 @@ describe("the backup lock", () => { expect(existsSync(lockPath(outDir))).toBe(false); await lib.close(); }); + + // `runBackup` takes a library object from its caller. With a + // `contentConcurrency` below 1, or one that is not a number, it would + // back up no original and report no error. It rejects any that is not a + // positive integer, as `BoundedPool` does, before it takes the lock, + // which would create the download directory, and before it asks the + // library for anything: the object here has nothing else. + it("rejects a library whose contentConcurrency is not a positive integer, before taking the lock", async () => { + const outDir = join(root, "backup"); + for (const contentConcurrency of [undefined, 0, -1, 1.5, NaN]) { + const lib = { contentConcurrency } as unknown as BackupLibrary; + + await expect( + runBackup(lib, { downloadDirectory: outDir }), + ).rejects.toThrow( + `contentConcurrency must be a positive integer, got ${contentConcurrency}`, + ); + + expect(existsSync(outDir)).toBe(false); + } + }); }); // Also serves ML data: file 100 has `ML_PAYLOAD`, the other two have none. @@ -1491,6 +1512,7 @@ describe("backup album folders", () => { thumbnail: async () => { throw new Error("no thumbnails in this stand-in"); }, + contentConcurrency: 5, fetchMLData: async () => {}, mlData: async () => undefined, whoami: () => ({ email: "backup@example.com", userID: USER_ID }), From 5a940d4e4e0494d8bc87941ab56b3c5b6cdb4a1b Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 16 Oct 2026 01:40:19 +0200 Subject: [PATCH 30/39] README says that `make install` needs nix (closes #200) `make install` runs `make build-bin`, which runs bun through `nix-shell -p bun`, so on a machine without nix it stops with `nix-shell: not found`, and the README named no such prerequisite. Getting Started now says what its commands need and that `make install` also needs nix, with a link to its download page. "Running the backup from cron" says the same where it introduces `make install`, and that a bun already installed does not help. The Makefile is unchanged, as the plan on the issue decided. TODO.md's Next Step names the four bugs that are open instead of saying none is. Judgement call: "node 22 or later" is the `engines` entry in package.json. Model: opus-5-5 --- README.md | 13 ++++++++++--- TODO.md | 9 ++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 12fd670..e474ac9 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ encrypting and uploading them back to the server. ## Getting Started +The commands below need git, node 22 or later, and yarn. `make install`, which +builds the single binary that "Running the backup from cron" uses, also needs +[nix](https://nixos.org/download). + ```bash git clone https://git.eeqj.de/sneak/quak.git cd quak @@ -86,9 +90,12 @@ enumeration/download calls) is exported too and documented under Design below. ## Running the backup from cron `quak backup` never prompts, so cron can run it. `make install` builds quak as a -single binary and copies it to `~/bin/quak`. Log in once with it, as the user -the cron job will run as; the session is saved in that user's data directory -(see "Session handling"): +single binary and copies it to `~/bin/quak`. Besides what Getting Started needs, +it needs [nix](https://nixos.org/download), even where bun is already installed: +`make install` runs `make build-bin`, which bundles the built `dist/` with bun +run through `nix-shell -p bun`. Log in once with `~/bin/quak`, as the user the +cron job will run as; the session is saved in that user's data directory (see +"Session handling"): ```bash ~/bin/quak login diff --git a/TODO.md b/TODO.md index 1aa7f9b..f077269 100644 --- a/TODO.md +++ b/TODO.md @@ -17,7 +17,10 @@ pre-1.0 # Next Step -None: no implementation work is open. The check of quak against a live Ente +Four bugs are open: https://git.eeqj.de/sneak/quak/issues/196, +https://git.eeqj.de/sneak/quak/issues/197, +https://git.eeqj.de/sneak/quak/issues/198 and +https://git.eeqj.de/sneak/quak/issues/199. The check of quak against a live Ente account, https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak has, so it waits on him. @@ -26,6 +29,10 @@ declares one. # Completed Steps +- 2026-10-15: The README says that `make install` needs nix (issue 200), in + Getting Started and in "Running the backup from cron": `make build-bin` runs + bun through `nix-shell -p bun`. The `Makefile` is unchanged. + - 2026-10-14: `lib.backup()`, and so `quak backup`, works on up to the content pool's limit of originals at once, 5 by default, instead of one at a time (issue 182). That many workers take files from one shared list, each doing all From d016dbfaa7a1994c8b9286fa25a3ba47d30b36d4 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 16 Oct 2026 03:23:16 +0200 Subject: [PATCH 31/39] A network failure is printed and recorded with its reason (closes #196) Node's `fetch` rejects every failure below HTTP as `fetch failed` and puts the reason on `cause`. `describeError` in `src/errors.ts` gives the message followed by the messages on the cause chain, and for an `AggregateError`, which Node gives when every address of a host fails, the message of each address. `run` prints it, and the backup logs and records it. `classify` reads that text and calls a failure transient when `hasTransportCode`, now exported by `src/retry.ts`, finds a transport errno on the chain. A failed ML data fetch keeps its error as the cause. The bounded walk along the chain moved from `src/retry.ts` to `src/errors.ts`. A truncated download's message leaves the authentication failure to its cause. Model: opus-5-5 --- README.md | 15 ++- TODO.md | 21 ++++- src/backup.ts | 37 +++++--- src/cli-run.ts | 13 +-- src/download/index.ts | 8 +- src/errors.ts | 74 ++++++++++++++- src/retry.ts | 28 +++--- test/cli/backup-end-to-end.test.ts | 144 +++++++++++++++++++++++++++++ test/cli/run.test.ts | 80 ++++++++++++++++ test/download/download.test.ts | 11 ++- 10 files changed, 382 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index e474ac9..3fc1506 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,18 @@ matters. | `2` | Another backup of the same directory is running. This run sent no request and changed nothing. | | `3` | There is no usable session: none is saved, the saved one is corrupt, or the server no longer accepts it. Run `quak login` as the user the cron job runs as. | +`<message>` is the error's message, followed by the message of each error that +caused it. Under Node, as `yarn quak` runs quak, `fetch` reports every failure +below HTTP as just `fetch failed` and gives the reason as its cause, so a run +that could not reach the server prints, for example, +`quak: fetch failed: connect ECONNREFUSED 1.2.3.4:443`. When the server has +several addresses and none of them could be reached, the line gives the reason +for each, separated by `, `. A file whose download failed that way is in +`failures.json` with that error, as a transient failure. So is each file with no +ML data cached when the ML data fetch failed that way, its error starting +`ML data: `. The `~/bin/quak` that `make install` builds runs under bun, and +prints bun's own wording for such a failure. + 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 decrypt, or a file in `failures.json` because quak could not decrypt it, keeps @@ -354,7 +366,8 @@ quak/ exif.ts EXIF read from an image's bytes with exifreader mldata-fetch.ts fetch + decrypt per-file ML data filename.ts safe file names from server metadata - errors.ts error types shared across layers + errors.ts error types shared across layers, and how an error + is described retry.ts retry classifier + exponential backoff with jitter thumbnails.ts detect + regenerate missing thumbnails client.ts high-level Client class assembled from the above diff --git a/TODO.md b/TODO.md index f077269..6ef08dc 100644 --- a/TODO.md +++ b/TODO.md @@ -17,10 +17,11 @@ pre-1.0 # Next Step -Four bugs are open: https://git.eeqj.de/sneak/quak/issues/196, -https://git.eeqj.de/sneak/quak/issues/197, -https://git.eeqj.de/sneak/quak/issues/198 and -https://git.eeqj.de/sneak/quak/issues/199. The check of quak against a live Ente +Five bugs are open: https://git.eeqj.de/sneak/quak/issues/197, +https://git.eeqj.de/sneak/quak/issues/198, +https://git.eeqj.de/sneak/quak/issues/199, +https://git.eeqj.de/sneak/quak/issues/205 and +https://git.eeqj.de/sneak/quak/issues/206. The check of quak against a live Ente account, https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak has, so it waits on him. @@ -29,6 +30,18 @@ declares one. # Completed Steps +- 2026-10-15: A network failure is printed and recorded with its reason, not as + just `fetch failed` (issue 196). `describeError` in `src/errors.ts` gives an + error's message followed by the message of each error on its `cause` chain, + such as `fetch failed: connect ECONNREFUSED 1.2.3.4:443`, and each address's + reason when a host has several and none could be reached. `run` prints that, + and the backup logs it and records it in `failures.json`, where `classify` now + calls the failure transient when the chain holds an errno that + `hasTransportCode` in `src/retry.ts` recognises, for a failed ML data fetch + too. The walk along the chain moved from `src/retry.ts` to `src/errors.ts`, + which both use. Tests refuse every connection of a backup, then only one + file's download, and fail the ML data fetch. + - 2026-10-15: The README says that `make install` needs nix (issue 200), in Getting Started and in "Running the backup from cron": `make build-bin` runs bun through `nix-shell -p bun`. The `Makefile` is unchanged. diff --git a/src/backup.ts b/src/backup.ts index 488ff30..16d2da9 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -82,6 +82,7 @@ import { init, } from "./crypto/index.js"; import { removeLeftoverTempFiles } from "./download/index.js"; +import { describeError } from "./errors.js"; import { sanitizeFileName, withExtension } from "./filename.js"; import { copyAtomic, @@ -94,6 +95,7 @@ import type { Undecryptable } from "./library/store.js"; import { extractImageMetadata } from "./metadata-backup.js"; import type { MLData } from "./mldata-fetch.js"; import type { Collection, EnteFile, FileMetadata } from "./model/types.js"; +import { hasTransportCode } from "./retry.js"; export type ProgressCallback = (message: string) => void; @@ -219,11 +221,13 @@ const isPresent = (path: string): boolean => { // Best-effort classification for the ledger. Retryable server/network problems // are transient; refusals and local filesystem/decrypt errors are permanent; -// anything else is unknown. Both the error code and message are inspected. +// anything else is unknown. The error code and the error as `describeError` +// gives it are inspected, and so is each errno on the error's cause chain, +// where Node's `fetch` puts it. const classify = (err: unknown): FailureClass => { + if (hasTransportCode(err)) return "transient"; const e = err as NodeJS.ErrnoException; - const text = - `${e?.code ?? ""} ${err instanceof Error ? err.message : String(err)}`.toLowerCase(); + const text = `${e?.code ?? ""} ${describeError(err)}`.toLowerCase(); if ( /timeout|timed out|econnreset|econnrefused|econnaborted|network|socket|eai_again|throttl|temporarily|429|500|502|503|504/.test( text, @@ -241,9 +245,6 @@ const classify = (err: unknown): FailureClass => { return "unknown"; }; -const errorMessage = (err: unknown): string => - err instanceof Error ? err.message : String(err); - // Ensure `linkPath` is a symlink to `target`, rebuilding a missing, wrong, or // non-symlink entry. Throws on failure (a directory in the way, no permission) // so the caller records it and moves on rather than aborting the run. @@ -470,7 +471,7 @@ const imageMetadataFor = async ( const bytes = await readFile(originalPath); return { imageMetadata: extractImageMetadata(bytes) ?? {} }; } catch (err) { - return { imageMetadataError: errorMessage(err) }; + return { imageMetadataError: describeError(err) }; } }; @@ -610,7 +611,7 @@ const runLockedBackup = async ( // and thumbnail both fail this run must not double its attempt count // or appear twice in errors. if (failedThisRun.has(fileID)) return; - const error = errorMessage(err); + const error = describeError(err); errors.push({ fileID, title, collection, error }); const prior = ledger.get(fileID); ledger.set(fileID, { @@ -681,7 +682,7 @@ const runLockedBackup = async ( } } catch (err) { log( - `FAILED verifying original ${file.metadata.title}: ${errorMessage(err)}`, + `FAILED verifying original ${file.metadata.title}: ${describeError(err)}`, ); recordFailure( file.id, @@ -717,7 +718,9 @@ const runLockedBackup = async ( } downloaded++; } catch (err) { - log(`FAILED original ${file.metadata.title}: ${errorMessage(err)}`); + log( + `FAILED original ${file.metadata.title}: ${describeError(err)}`, + ); recordFailure( file.id, file.metadata.title, @@ -785,12 +788,14 @@ const runLockedBackup = async ( // instead and is recorded as failed. The next run fetches its ML data // again because none is cached. if (includeOriginals) { + let mlDataFailure: unknown; let mlDataError: string | undefined; try { log("Fetching ML data..."); await lib.fetchMLData(); } catch (err) { - mlDataError = errorMessage(err); + mlDataFailure = err; + mlDataError = describeError(err); log(`FAILED ML data: ${mlDataError}`); } for (const file of distinct.values()) { @@ -814,7 +819,7 @@ const runLockedBackup = async ( file.id, file.metadata.title, collectionName.get(file.collectionID) ?? "", - new Error(`ML data: ${mlDataError}`), + new Error("ML data", { cause: mlDataFailure }), ); } else { writeSidecar(path, file, { mlData }, image); @@ -846,7 +851,7 @@ const runLockedBackup = async ( downloadDirectory, ); } catch (err) { - log(`FAILED removing old album directories: ${errorMessage(err)}`); + log(`FAILED removing old album directories: ${describeError(err)}`); } for (const c of collections) { @@ -868,7 +873,9 @@ const runLockedBackup = async ( try { removeStaleLinks(colDir, new Set(linkNames), downloadDirectory); } catch (err) { - log(`FAILED removing old links in ${c.name}: ${errorMessage(err)}`); + log( + `FAILED removing old links in ${c.name}: ${describeError(err)}`, + ); } const metaFiles = files.map((f) => ({ @@ -885,7 +892,7 @@ const runLockedBackup = async ( ); } catch (err) { log( - `FAILED symlink ${c.name}/${linkName}: ${errorMessage(err)}`, + `FAILED symlink ${c.name}/${linkName}: ${describeError(err)}`, ); recordFailure( link.file.id, diff --git a/src/cli-run.ts b/src/cli-run.ts index 027bc40..50bf822 100644 --- a/src/cli-run.ts +++ b/src/cli-run.ts @@ -2,14 +2,17 @@ import type { Writable } from "node:stream"; import { ApiError } from "./api/client.js"; +import { describeError } from "./errors.js"; // Run a command and exit with its code once stdout/stderr have drained. // Exiting before the drain can truncate piped output, and the library can keep // the event loop alive after a command returns, so a plain return could hang. // An error the command throws is printed as one `quak: MESSAGE` line, without -// the stack trace, and exits 1. A 401 from the server means it no longer -// accepts the saved session: that prints one line saying to log in again and -// exits 3, as a missing or corrupt session file does. +// the stack trace, and exits 1. MESSAGE is the error as `describeError` gives +// it, so it carries the reason Node's `fetch` puts on `cause`. A 401 from the +// server means it no longer accepts the saved session: that prints one line +// saying to log in again and exits 3, as a missing or corrupt session file +// does. export const run = async ( command: Promise<number>, stdout: Writable, @@ -26,9 +29,7 @@ export const run = async ( ); code = 3; } else { - stderr.write( - `quak: ${err instanceof Error ? err.message : String(err)}\n`, - ); + stderr.write(`quak: ${describeError(err)}\n`); code = 1; } } diff --git a/src/download/index.ts b/src/download/index.ts index e27184b..1e8b30a 100644 --- a/src/download/index.ts +++ b/src/download/index.ts @@ -135,15 +135,17 @@ const streamDecrypt = async ( // cannot tell a partial chunk from a corrupt one, so this // is reported as the truncation it almost always is, with // the authentication failure kept as the error's cause. - // Only the pull is guarded: a sink failure on a chunk - // that did authenticate is a disk error, not a + // The message leaves that failure to the cause, which + // `describeError` appends to what `quak backup` prints and + // records. Only the pull is guarded: a sink failure on a + // chunk that did authenticate is a disk error, not a // truncation. let pulled; try { pulled = pullStreamChunk(state, buffer); } catch (err) { throw new TruncatedStreamError( - `download: stream truncated: response body ended with ${buffer.length} trailing bytes that did not authenticate as a final chunk (transfer stopped mid-chunk, or the data is corrupt)`, + `download: stream truncated: response body ended with ${buffer.length} trailing bytes (transfer stopped mid-chunk, or the data is corrupt)`, { cause: err }, ); } diff --git a/src/errors.ts b/src/errors.ts index e001092..d9970bd 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,10 +1,13 @@ -// Error types that more than one layer of quak needs to recognise. +// Error types that more than one layer of quak needs to recognise, and how an +// error is described in what quak prints and in `failures.json`. // -// They live here rather than beside the code that throws them so that the +// The types live here rather than beside the code that throws them so that the // retry classifier can identify them without importing the HTTP client or the // download layer — both of which import the classifier. `ApiError` is // re-exported from `src/api/client.ts`, which is where callers have always -// imported it from and where it still belongs conceptually. +// imported it from and where it still belongs conceptually. The walk along an +// error's `cause` chain is here too, because the retry classifier reads the +// chain for errnos and `describeError` for messages. export class ApiError extends Error { readonly status: number; @@ -43,3 +46,68 @@ export class TruncatedStreamError extends Error { this.name = "TruncatedStreamError"; } } + +// `cause` is an arbitrary user-settable property and nothing prevents it from +// forming a cycle, so the walk is bounded. Hanging the process would be worse +// than misclassifying an error or describing only part of it. +const MAX_CAUSE_DEPTH = 8; + +// The error and each error on its `cause` chain, top first, at most +// `MAX_CAUSE_DEPTH` of them. `complete` is false when the walk stopped at the +// depth limit with more of the chain still below it. +export const causeChain = ( + err: unknown, +): { chain: object[]; complete: boolean } => { + const chain: object[] = []; + let current: unknown = err; + for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) { + if (current === null || typeof current !== "object") { + return { chain, complete: true }; + } + chain.push(current); + const { cause } = current as { cause?: unknown }; + if (cause === current) return { chain, complete: true }; + current = cause; + } + return { chain, complete: current === null || typeof current !== "object" }; +}; + +// An error as quak prints it and records it in `failures.json`: its message, +// then the message of each error on its `cause` chain, joined with ": ". Node's +// `fetch` rejects with just "fetch failed" and puts the reason on `cause`, so +// this gives "fetch failed: connect ECONNREFUSED 1.2.3.4:443". When the host +// has several addresses and every one fails, the cause is an `AggregateError` +// with an empty message and one error per address in `errors`, so an +// `AggregateError` is given as the messages of its errors, joined with ", ". +// A cause that is not an object, such as a thrown string, is given as +// `String(cause)`. Each message is trimmed of the white space around it, as +// OpenSSL ends its own with a newline. A message the text already holds is +// left out. +export const describeError = (err: unknown): string => { + if (!(err instanceof Error)) return String(err); + const { chain } = causeChain(err); + const messages = chain + .slice(1) + .map((cause) => + cause instanceof AggregateError + ? cause.errors + .map((e: unknown) => + (e instanceof Error ? e.message : String(e)).trim(), + ) + .join(", ") + : (cause as { message?: unknown }).message, + ); + // `causeChain` stops at a cause that is not an object, so that one is read + // from the last error on the chain. + const last = chain[chain.length - 1] as { cause?: unknown }; + if (last.cause !== undefined && typeof last.cause !== "object") { + messages.push(String(last.cause)); + } + let text = err.message.trim(); + for (const message of messages) { + if (typeof message !== "string") continue; + const trimmed = message.trim(); + if (!text.includes(trimmed)) text += `: ${trimmed}`; + } + return text; +}; diff --git a/src/retry.ts b/src/retry.ts index ffa68d9..09bb752 100644 --- a/src/retry.ts +++ b/src/retry.ts @@ -10,7 +10,7 @@ // declining to retry a transient one costs a single file that the next run // picks up anyway. When the evidence is ambiguous, fail fast. -import { ApiError, TruncatedStreamError } from "./errors.js"; +import { ApiError, causeChain, TruncatedStreamError } from "./errors.js"; export interface RetryOptions { // Total calls, not retries: `attempts: 1` disables retrying. @@ -89,11 +89,6 @@ const TRANSPORT_CODES = new Set([ // narrowed. See `isSafeToReplay`. const CONNECT_CODES = new Set(["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED"]); -// `cause` is an arbitrary user-settable property and nothing prevents it from -// forming a cycle, so the walk is bounded. Hanging the process would be a -// worse outcome than any misclassification. -const MAX_CAUSE_DEPTH = 8; - // Collect every `code` in an error's cause chain. undici does not put the // errno on the error it throws — it hangs the underlying socket error off // `cause`, sometimes more than one level down — so a classifier that only read @@ -101,20 +96,21 @@ const MAX_CAUSE_DEPTH = 8; // connection permanent. `complete` is false when the walk stopped at the // depth limit with more of the chain still below it. const causeCodes = (err: unknown): { codes: string[]; complete: boolean } => { + const { chain, complete } = causeChain(err); const codes: string[] = []; - let current: unknown = err; - for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) { - if (current === null || typeof current !== "object") { - return { codes, complete: true }; - } - const { code, cause } = current as { code?: unknown; cause?: unknown }; + for (const link of chain) { + const { code } = link as { code?: unknown }; if (typeof code === "string") codes.push(code); - if (cause === current) return { codes, complete: true }; - current = cause; } - return { codes, complete: current === null || typeof current !== "object" }; + return { codes, complete }; }; +// Does the error's cause chain hold the errno of a transport failure, one of +// `TRANSPORT_CODES`? `isRetryable` retries such an error, and `classify` in +// `src/backup.ts` calls it transient. +export const hasTransportCode = (err: unknown): boolean => + causeCodes(err).codes.some((code) => TRANSPORT_CODES.has(code)); + const isAbort = (err: unknown): boolean => { if (err === null || typeof err !== "object") return false; const { name } = err as { name?: unknown }; @@ -158,7 +154,7 @@ export const isRetryable = (err: unknown): boolean => { // have succeeded; the cost of the imprecision is bounded by the attempt // count. if (err instanceof TypeError) return true; - return causeCodes(err).codes.some((code) => TRANSPORT_CODES.has(code)); + return hasTransportCode(err); }; // Could the first attempt already have taken effect on the server? diff --git a/test/cli/backup-end-to-end.test.ts b/test/cli/backup-end-to-end.test.ts index 65bf5b6..367251e 100644 --- a/test/cli/backup-end-to-end.test.ts +++ b/test/cli/backup-end-to-end.test.ts @@ -329,6 +329,41 @@ const downloadOf = (input: RequestInfo | URL): number | undefined => { : undefined; }; +// The error Node gives for a connection to `address` that was refused. +const refusedAt = (address: string): Error => + Object.assign(new Error(`connect ECONNREFUSED ${address}`), { + code: "ECONNREFUSED", + }); + +// What Node's `fetch` rejects with when the server refuses the connection, as +// for every failure below HTTP: a TypeError that says only "fetch failed", +// with the reason, and its errno, as its cause. When the server has several +// addresses and each one refuses, the cause is an AggregateError with an empty +// message, one error per address and the errno of the first. `reason` is the +// error as quak prints it and records it in `failures.json`. +const REFUSED = [ + { + addresses: "its one address", + error: (): TypeError => + new TypeError("fetch failed", { cause: refusedAt("1.2.3.4:443") }), + reason: "fetch failed: connect ECONNREFUSED 1.2.3.4:443", + }, + { + addresses: "each of its two addresses", + error: (): TypeError => + new TypeError("fetch failed", { + cause: Object.assign( + new AggregateError([ + refusedAt("2001:db8::1:443"), + refusedAt("1.2.3.4:443"), + ]), + { code: "ECONNREFUSED" }, + ), + }), + reason: "fetch failed: connect ECONNREFUSED 2001:db8::1:443, connect ECONNREFUSED 1.2.3.4:443", + }, +]; + // The fake server, pushing onto `downloads` the ID of each file whose original // is downloaded. const recordingDownloads = @@ -619,6 +654,115 @@ describe("quak backup", () => { expect(readdirSync(dir)).toEqual([]); }); + // The server refuses every connection, at its one address or at each of + // several. quak tries each request again, here without waiting, and then + // the run stops at the refresh it starts with. The line it prints gives + // the reason Node's `fetch` put on the error's cause, so a cron log tells + // a refused connection from a name that did not resolve, and the exit + // code is 1. + it.each(REFUSED)( + "prints why the refresh could not reach the server at $addresses, and exits 1", + async ({ error, reason }) => { + const dir = mkdtempSync(join(root, "backup-")); + const fetch: typeof globalThis.fetch = async () => { + throw error(); + }; + + const { code, stderr } = await backup(dir, {}, fetch); + + expect(code).toBe(1); + expect(stderr).toBe( + "Starting backup...\n" + + "Refreshing library...\n" + + `quak: ${reason}\n`, + ); + }, + ); + + // The files CDN refuses every connection for toast.mp4, at its one + // address or at each of several. Once its retries are spent, the file + // goes into `failures.json` with the reason, as a transient failure, + // since a later run may well reach the CDN, and the lines the backup + // prints for it give the same reason. The rest of the account is backed + // up, and the exit code is 1. + it.each(REFUSED)( + "records why a download could not reach the server at $addresses in failures.json, as transient, and exits 1", + async ({ error, reason }) => { + let attempts = 0; + const fetch: typeof globalThis.fetch = async (input, init) => { + if (downloadOf(input) === 200) { + attempts++; + throw error(); + } + return server(input, init); + }; + const dir = mkdtempSync(join(root, "backup-")); + + const { code, stderr } = await backup(dir, {}, fetch); + + expect(code).toBe(1); + expect(attempts).toBe(UNATTENDED_RETRY_OPTIONS.attempts); + const failures = readJSON(join(dir, "failures.json")); + expect(Object.keys(failures.files)).toEqual(["200"]); + expect(failures.files["200"]).toMatchObject({ + fileID: 200, + title: "toast.mp4", + classification: "transient", + attempts: 1, + error: reason, + }); + expect(stderr).toContain(`FAILED original toast.mp4: ${reason}\n`); + expect(stderr).toContain( + `[Friend's Wedding] toast.mp4 (id 200): ${reason}\n`, + ); + expect(readFileSync(join(dir, DAY, "2024-03-01.100.jpg"))).toEqual( + Buffer.from(JPEG_WITH_EXIF), + ); + expect(existsSync(join(dir, DAY, "2024-03-01.200.mp4"))).toBe( + false, + ); + }, + ); + + // The name of Ente's API server does not resolve when quak fetches the ML + // data, after every original has been backed up. No file has ML data + // cached yet, so each goes into `failures.json` with the reason, as a + // transient failure, as a download that could not reach the server does, + // and the exit code is 1. + it("records why the ML data fetch could not reach the server in failures.json, as transient, and exits 1", async () => { + const fetch: typeof globalThis.fetch = async (input, init) => { + if (new URL(String(input)).pathname === "/files/data/fetch") { + throw new TypeError("fetch failed", { + cause: Object.assign( + new Error("getaddrinfo ENOTFOUND api.ente.io"), + { code: "ENOTFOUND" }, + ), + }); + } + return server(input, init); + }; + const dir = mkdtempSync(join(root, "backup-")); + + const { code, stderr } = await backup(dir, {}, fetch); + + expect(code).toBe(1); + const error = + "ML data: fetch failed: getaddrinfo ENOTFOUND api.ente.io"; + expect(stderr).toContain(`FAILED ${error}\n`); + const failures = readJSON(join(dir, "failures.json")); + expect( + Object.values(failures.files).map((f) => [ + f.fileID, + f.classification, + f.error, + ]), + ).toEqual([ + [100, "transient", error], + [200, "transient", error], + [300, "transient", error], + ]); + }); + // Something changed beach.jpg on disk after it was backed up. A run with // --verify hashes every stored original, finds that one no longer matches // the hash Ente recorded, says so, and downloads it again; it put back the diff --git a/test/cli/run.test.ts b/test/cli/run.test.ts index cfad348..27db619 100644 --- a/test/cli/run.test.ts +++ b/test/cli/run.test.ts @@ -48,6 +48,78 @@ describe("run", () => { ); }); + // Node's `fetch` rejects every failure below HTTP with a TypeError that + // says only "fetch failed", and hangs the reason, with its errno, off + // `cause`. The printed line carries the reason, so a cron log tells a + // refused connection from a name that did not resolve. + it("prints the reason a fetch failed, from the error's cause", async () => { + const refused = new TypeError("fetch failed", { + cause: Object.assign( + new Error("connect ECONNREFUSED 1.2.3.4:443"), + { code: "ECONNREFUSED" }, + ), + }); + const result = await runToExit(Promise.reject(refused)); + expect(result).toEqual({ + code: 1, + stdout: "", + stderr: "quak: fetch failed: connect ECONNREFUSED 1.2.3.4:443\n", + }); + }); + + // When the host has several addresses and every one refuses, Node's + // `fetch` gives as the cause an AggregateError with an empty message and + // one error per address. The printed line gives each address's reason. + it("prints the reason for each address a fetch could not reach", async () => { + const refusedAt = (address: string): Error => + Object.assign(new Error(`connect ECONNREFUSED ${address}`), { + code: "ECONNREFUSED", + }); + const refused = new TypeError("fetch failed", { + cause: Object.assign( + new AggregateError([ + refusedAt("2001:db8::1:443"), + refusedAt("1.2.3.4:443"), + ]), + { code: "ECONNREFUSED" }, + ), + }); + const result = await runToExit(Promise.reject(refused)); + expect(result).toEqual({ + code: 1, + stdout: "", + stderr: "quak: fetch failed: connect ECONNREFUSED 2001:db8::1:443, connect ECONNREFUSED 1.2.3.4:443\n", + }); + }); + + // An error that already quotes its cause's message is printed as it is. + it("does not print a cause's message twice", async () => { + const reset = new Error("read ECONNRESET"); + const wrapped = new Error(`download: file 7: ${reset.message}`, { + cause: reset, + }); + const result = await runToExit(Promise.reject(wrapped)); + expect(result.stderr).toBe("quak: download: file 7: read ECONNRESET\n"); + }); + + // OpenSSL ends its message for a failed TLS connection, such as one to a + // server that answers in plain HTTP, with a newline. The printed line is + // still one line. + it("prints a cause's message without the white space around it", async () => { + const wrongVersion = new TypeError("fetch failed", { + cause: Object.assign( + new Error( + "C0B73A55A47F0000:error:0A00010B:SSL routines:ssl3_get_record:wrong version number:../deps/openssl/openssl/ssl/record/ssl3_record.c:354:\n", + ), + { code: "ERR_SSL_WRONG_VERSION_NUMBER" }, + ), + }); + const result = await runToExit(Promise.reject(wrongVersion)); + expect(result.stderr).toBe( + "quak: fetch failed: C0B73A55A47F0000:error:0A00010B:SSL routines:ssl3_get_record:wrong version number:../deps/openssl/openssl/ssl/record/ssl3_record.c:354:\n", + ); + }); + it("prints a thrown value that is not an Error", async () => { const result = await runToExit(Promise.reject("offline")); expect(result).toEqual({ @@ -57,6 +129,14 @@ describe("run", () => { }); }); + // A cause that is not an Error, such as a thrown string, is printed as a + // thrown value that is not an Error is. + it("prints a cause that is not an Error", async () => { + const wrapped = new Error("ML data", { cause: "offline" }); + const result = await runToExit(Promise.reject(wrapped)); + expect(result.stderr).toBe("quak: ML data: offline\n"); + }); + it("on a 401 from the server says to run quak login, on one line, and exits 3", async () => { const result = await runToExit( Promise.reject(new ApiError("unauthorized", 401)), diff --git a/test/download/download.test.ts b/test/download/download.test.ts index 06788fd..7333b36 100644 --- a/test/download/download.test.ts +++ b/test/download/download.test.ts @@ -74,7 +74,11 @@ import { } from "vitest"; import { init, toBase64, STREAM_CHUNK_SIZE } from "../../src/crypto/index.js"; import { ApiClient } from "../../src/api/client.js"; -import { ApiError, TruncatedStreamError } from "../../src/errors.js"; +import { + ApiError, + describeError, + TruncatedStreamError, +} from "../../src/errors.js"; import type { RetryOptions } from "../../src/retry.js"; import { downloadFile, @@ -834,6 +838,11 @@ describe.each(entryPoints)( expect(((err as Error).cause as Error).message).toMatch( /authentication failed/i, ); + // As quak prints it and records it in `failures.json`, the error + // states the authentication failure once, from its cause. + expect(describeError(err)).toMatch( + /^download: stream truncated: response body ended with \d+ trailing bytes \(transfer stopped mid-chunk, or the data is corrupt\): secretstream chunk authentication failed$/, + ); // And, as with every other failure, the destination is untouched // and no staged temp file survives. From 20410f8f71ebe38fbe7f68b57039e10f6d511249 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 16 Oct 2026 04:56:20 +0200 Subject: [PATCH 32/39] The binary `make install` builds retries a refused or unresolvable connection (closes #206) Bun's `fetch`, which `~/bin/quak` runs under, rejects with a plain `Error` that has no `cause` and a code of bun's own. Running the built binary against local listeners gave `ConnectionRefused` for a refused connection and a failed TLS handshake, and `ConnectionRefused` or `FailedToOpenSocket` for a name that does not resolve. Both join `TRANSPORT_CODES` in `src/retry.ts`, so they are retried and called transient. They stay out of `CONNECT_CODES`, so a `POST` or `PUT` is not replayed on them, because bun also gives them when it resends a request on a new connection. `classify` in `src/backup.ts` records a `TimeoutError` as transient, so a stalled download is no longer `unknown`. Not changed: a certificate bun rejects is not retried. Model: opus-5-5 --- README.md | 25 +++++++-- TODO.md | 20 +++++-- src/backup.ts | 5 +- src/retry.ts | 34 +++++++++--- test/cli/backup-end-to-end.test.ts | 84 ++++++++++++++++++++++++++++-- test/retry/retry.test.ts | 84 ++++++++++++++++++++++++++++++ 6 files changed, 233 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 3fc1506..5a26fe9 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,9 @@ for each, separated by `, `. A file whose download failed that way is in `failures.json` with that error, as a transient failure. So is each file with no ML data cached when the ML data fetch failed that way, its error starting `ML data: `. The `~/bin/quak` that `make install` builds runs under bun, and -prints bun's own wording for such a failure. +prints bun's own wording for such a failure, with no reason after it, such as +`quak: Unable to connect. Is the computer able to access the url?` for a refused +connection. It records such a failure in `failures.json` as transient too. After a `1`, the next run fetches each missing original again, fetches the ML data that is not cached, and rebuilds the album links. An album quak could not @@ -494,6 +496,17 @@ request is repeated only when repeating it could produce a different answer: declining to retry a transient one costs a single file that the next run picks up. +The `~/bin/quak` that `make install` builds runs under bun, whose `fetch` +rejects with a plain `Error` that has no `cause` and carries a code of bun's +own. quak retries a refused connection, a name that does not resolve, a reset +connection and a deadline under bun as it does under Node. Bun reports a refused +connection, and a TLS handshake that failed, as `ConnectionRefused`. For a name +that does not resolve it gives `ConnectionRefused` on some requests and +`FailedToOpenSocket` on others. A connection reset or closed before the response +ended is `ECONNRESET`, as under Node, and a deadline is the same `TimeoutError`. +A certificate bun rejects, reported with a code such as +`DEPTH_ZERO_SELF_SIGNED_CERT`, is not retried. + Backoff is exponential with full jitter: the delay before retry _n_ is `random() * min(maxDelayMs, baseDelayMs * 2 ** (n - 1))`. The exponential term is the ceiling and the wait is drawn below it, so a client that lost many @@ -549,9 +562,13 @@ routing errnos `EHOSTUNREACH`, `ENETUNREACH` and `ENETDOWN` are excluded for the same reason, despite looking like connect-time failures: on Linux an ICMP unreachable arriving mid-flight, or a local interface going down after the request was written, delivers them on an already-established socket. They stay -retryable for the idempotent calls. `putFile` is exempt: a presigned PUT stores -one whole object at one key in one request, so replaying it has no partial state -to damage. +retryable for the idempotent calls. So do bun's `ConnectionRefused` and +`FailedToOpenSocket`, on which a `POST` or `PUT` is not replayed either. When a +connection bun kept open from an earlier request closes before any answer, bun +sends the request again on a new connection, and if that one fails it gives one +of these codes although the server may already have the request. `putFile` is +exempt: a presigned PUT stores one whole object at one key in one request, so +replaying it has no partial state to damage. A download is retried as a whole — request, stream consumption, and decryption — because a socket reset after the response headers have arrived surfaces in the diff --git a/TODO.md b/TODO.md index 6ef08dc..a8e0a9d 100644 --- a/TODO.md +++ b/TODO.md @@ -17,11 +17,10 @@ pre-1.0 # Next Step -Five bugs are open: https://git.eeqj.de/sneak/quak/issues/197, +Four bugs are open: https://git.eeqj.de/sneak/quak/issues/197, https://git.eeqj.de/sneak/quak/issues/198, -https://git.eeqj.de/sneak/quak/issues/199, -https://git.eeqj.de/sneak/quak/issues/205 and -https://git.eeqj.de/sneak/quak/issues/206. The check of quak against a live Ente +https://git.eeqj.de/sneak/quak/issues/199 and +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 has, so it waits on him. @@ -30,6 +29,19 @@ declares one. # Completed Steps +- 2026-10-16: The `~/bin/quak` that `make install` builds retries a refused + connection and a name that does not resolve, and the backup records either as + transient (issue 206). Bun's `fetch` reports them with codes of its own, + `ConnectionRefused` and `FailedToOpenSocket`, which `src/retry.ts` now counts + as transport failures. A `POST` or `PUT` is not replayed on them, because bun + also gives them when it resends a request on a new connection after a + connection it kept open closed, and the server may already have the request. + Bun already gave `ECONNRESET` for a reset and a `TimeoutError` for a deadline. + `classify` in `src/backup.ts` now records any `TimeoutError` as transient, so + a stalled download, whose error says only that it stalled, is no longer + `unknown`. The shapes were found by running the binary against local + listeners. + - 2026-10-15: A network failure is printed and recorded with its reason, not as just `fetch failed` (issue 196). `describeError` in `src/errors.ts` gives an error's message followed by the message of each error on its `cause` chain, diff --git a/src/backup.ts b/src/backup.ts index 16d2da9..3c2b026 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -223,10 +223,13 @@ const isPresent = (path: string): boolean => { // are transient; refusals and local filesystem/decrypt errors are permanent; // anything else is unknown. The error code and the error as `describeError` // gives it are inspected, and so is each errno on the error's cause chain, -// where Node's `fetch` puts it. +// where Node's `fetch` puts it. An error named `TimeoutError` is transient +// whatever its message says, as `isRetryable` retries it: each of quak's +// deadlines aborts with one, and the download's says only that it stalled. const classify = (err: unknown): FailureClass => { if (hasTransportCode(err)) return "transient"; const e = err as NodeJS.ErrnoException; + if (e?.name === "TimeoutError") return "transient"; const text = `${e?.code ?? ""} ${describeError(err)}`.toLowerCase(); if ( /timeout|timed out|econnreset|econnrefused|econnaborted|network|socket|eai_again|throttl|temporarily|429|500|502|503|504/.test( diff --git a/src/retry.ts b/src/retry.ts index 09bb752..c210bf7 100644 --- a/src/retry.ts +++ b/src/retry.ts @@ -58,6 +58,14 @@ export const resolveRetryOptions = ( }); // Transport failures: the request did not complete, for reasons below HTTP. +// +// The errnos are Node's. The binary `make install` builds runs under bun, +// whose `fetch` rejects with a plain `Error` that has no `cause` and a `code` +// of bun's own. Bun gives `ConnectionRefused` for a refused connection and for +// a TLS handshake that failed, and `ConnectionRefused` or `FailedToOpenSocket` +// for a name that does not resolve. For a connection reset or closed before +// the response ended, it gives Node's `ECONNRESET`. These were found by +// running that binary, built with bun 1.3.13, against listeners on 127.0.0.1. const TRANSPORT_CODES = new Set([ "ECONNRESET", "ECONNABORTED", @@ -70,6 +78,9 @@ const TRANSPORT_CODES = new Set([ "ENETUNREACH", "ENETRESET", "ENETDOWN", + // Bun's. + "ConnectionRefused", + "FailedToOpenSocket", ]); // The subset of the above that can only be reported before a TCP connection @@ -87,6 +98,12 @@ const TRANSPORT_CODES = new Set([ // ambiguity this set exists to exclude. They stay in `TRANSPORT_CODES`, so // they remain retryable for idempotent calls; only replay eligibility is // narrowed. See `isSafeToReplay`. +// +// Bun's `ConnectionRefused` and `FailedToOpenSocket` are absent too, so a +// `POST` or `PUT` is not replayed on them. When a connection bun kept open from +// an earlier request closes before any answer, bun sends the request again on +// a new connection, and if that one fails it gives one of these codes although +// the server may already have the request. const CONNECT_CODES = new Set(["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED"]); // Collect every `code` in an error's cause chain. undici does not put the @@ -105,7 +122,7 @@ const causeCodes = (err: unknown): { codes: string[]; complete: boolean } => { return { codes, complete }; }; -// Does the error's cause chain hold the errno of a transport failure, one of +// Does the error's cause chain hold the code of a transport failure, one of // `TRANSPORT_CODES`? `isRetryable` retries such an error, and `classify` in // `src/backup.ts` calls it transient. export const hasTransportCode = (err: unknown): boolean => @@ -152,7 +169,8 @@ export const isRetryable = (err: unknown): boolean => { // deliberately literal. Demanding a recognised `cause` instead would // classify real network failures as permanent and fail backups that should // have succeeded; the cost of the imprecision is bounded by the attempt - // count. + // count. Bun's fetch rejects with a plain `Error` instead, which is + // recognised by its code (see `TRANSPORT_CODES`). if (err instanceof TypeError) return true; return hasTransportCode(err); }; @@ -170,11 +188,13 @@ export const isRetryable = (err: unknown): boolean => { // // Everything else is ambiguous. A 5xx proves the server did process the // request. A reset or a broken pipe can arrive after it was fully sent and -// acted on. A routing errno can be delivered on an established socket. A -// deadline says nothing at all about the server's state. So every errno in the -// cause chain must be a connect errno: one other errno anywhere in the chain -// is doubt, and doubt is not replayed. A chain longer than the walk is doubt -// too: the links below the limit were never read. +// acted on. A routing errno can be delivered on an established socket. Bun's +// `ConnectionRefused` and `FailedToOpenSocket` can come from a new connection +// bun opened to resend a request that already went out on a connection it kept +// open. A deadline says nothing at all about the server's state. So every code in the +// cause chain must be one of `CONNECT_CODES`: one other code anywhere in the +// chain is doubt, and doubt is not replayed. A chain longer than the walk is +// doubt too: the links below the limit were never read. export const isSafeToReplay = (err: unknown): boolean => { const { codes, complete } = causeCodes(err); return ( diff --git a/test/cli/backup-end-to-end.test.ts b/test/cli/backup-end-to-end.test.ts index 367251e..1009cc0 100644 --- a/test/cli/backup-end-to-end.test.ts +++ b/test/cli/backup-end-to-end.test.ts @@ -275,13 +275,15 @@ afterAll(() => { // Run `quak backup <dir>` as `bin/quak.ts` runs it, sending its requests // through `fetch`, and return its exit code and everything it printed on // stderr. Its requests are retried as those of `quak backup` are, except that -// a retry does not wait. Its cache is beside `dir`, so each run of one -// directory finds the cache the run before it left, as cron's runs find the -// user's. +// a retry does not wait. A download is given up after `downloadTimeoutMs` with +// no bytes received, 60 seconds unless given. Its cache is beside `dir`, so +// each run of one directory finds the cache the run before it left, as cron's +// runs find the user's. const backup = async ( dir: string, opts: { verify?: boolean } = {}, fetch = server, + downloadTimeoutMs?: number, ): Promise<{ code: number; stderr: string }> => { let printed = ""; const stderr = new Writable({ @@ -308,6 +310,7 @@ const backup = async ( loadSession(path, { fetch, retry: { ...UNATTENDED_RETRY_OPTIONS, sleep: async () => {} }, + downloadTimeoutMs, }), login: prompted, prompt: prompted, @@ -724,6 +727,81 @@ describe("quak backup", () => { }, ); + // The download of toast.mp4 fails as bun's `fetch` reports a failure, in + // the binary `make install` builds: a plain `Error` with bun's own code + // and message, and no cause. These are what bun gave for a refused + // connection, a name that did not resolve and a reset connection. The + // file is tried as often as under Node, and goes into `failures.json` + // with that message, as a transient failure. + it.each([ + [ + "ConnectionRefused", + "Unable to connect. Is the computer able to access the url?", + ], + ["FailedToOpenSocket", "Was there a typo in the url or port?"], + [ + "ECONNRESET", + "The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()", + ], + ])( + "records a download that failed with bun's %s in failures.json, as transient", + async (bunCode, message) => { + let attempts = 0; + const fetch: typeof globalThis.fetch = async (input, init) => { + if (downloadOf(input) === 200) { + attempts++; + throw Object.assign(new Error(message), { + code: bunCode, + errno: 0, + }); + } + return server(input, init); + }; + const dir = mkdtempSync(join(root, "backup-")); + + const { code } = await backup(dir, {}, fetch); + + expect(code).toBe(1); + expect(attempts).toBe(UNATTENDED_RETRY_OPTIONS.attempts); + const failures = readJSON(join(dir, "failures.json")); + expect(failures.files["200"]).toMatchObject({ + classification: "transient", + error: message, + }); + }, + ); + + // The download of toast.mp4 stalls: the server sends the headers and then + // never a byte of the body. Each attempt is given up after 50 ms, quak's + // own deadline for this run, with a `TimeoutError` whose message says + // only that the download stalled. The file goes into `failures.json` as a + // transient failure, as a request that ran out of time is retried. + it("records a stalled download in failures.json, as transient", async () => { + let attempts = 0; + const fetch: typeof globalThis.fetch = async (input, init) => { + if (downloadOf(input) === 200) { + attempts++; + return new Response( + new ReadableStream<Uint8Array>({ + pull: () => new Promise<void>(() => {}), + }), + ); + } + return server(input, init); + }; + const dir = mkdtempSync(join(root, "backup-")); + + const { code } = await backup(dir, {}, fetch, 50); + + expect(code).toBe(1); + expect(attempts).toBe(UNATTENDED_RETRY_OPTIONS.attempts); + const failures = readJSON(join(dir, "failures.json")); + expect(failures.files["200"]).toMatchObject({ + classification: "transient", + error: "download stalled: no bytes received for 50 ms", + }); + }); + // The name of Ente's API server does not resolve when quak fetches the ML // data, after every original has been backed up. No file has ML data // cached yet, so each goes into `failures.json` with the reason, as a diff --git a/test/retry/retry.test.ts b/test/retry/retry.test.ts index 8f2213f..b943985 100644 --- a/test/retry/retry.test.ts +++ b/test/retry/retry.test.ts @@ -77,6 +77,14 @@ const recordingSleep = (): { const errnoError = (code: string, message = code): Error => Object.assign(new Error(message), { code }); +/** + * An error shaped like what bun's `fetch` rejects with for a failure below + * HTTP: a plain `Error` with bun's own message, a `code`, an `errno` of 0, and + * no `cause`. + */ +const bunError = (code: string, message: string): Error => + Object.assign(new Error(message), { code, errno: 0 }); + // --------------------------------------------------------------------------- // Classification // --------------------------------------------------------------------------- @@ -244,6 +252,82 @@ describe("isRetryable: transport failures", () => { }); }); +describe("isRetryable and isSafeToReplay: bun's fetch errors", () => { + // The binary `make install` builds runs quak under bun, whose `fetch` + // does not reject as Node's does. Each shape here was found by running + // that binary, built with bun 1.3.13, against listeners on 127.0.0.1 and + // a name under `.invalid`. + + it("retries a refused connection, and does not replay it", () => { + // Bun gives the same error when the TLS handshake fails, and when a + // new connection it opened to resend a request is refused. That + // request may already have reached the server on a connection bun + // kept open from an earlier request. + const refused = bunError( + "ConnectionRefused", + "Unable to connect. Is the computer able to access the url?", + ); + expect(isRetryable(refused)).toBe(true); + expect(isSafeToReplay(refused)).toBe(false); + }); + + it("retries a name that does not resolve, with either code bun gives, and does not replay it", () => { + // For the same name bun gave `ConnectionRefused` on some requests and + // `FailedToOpenSocket` on others. Either can also come from a new + // connection bun opened to resend a request, as above. + for (const unresolved of [ + bunError( + "ConnectionRefused", + "Unable to connect. Is the computer able to access the url?", + ), + bunError( + "FailedToOpenSocket", + "Was there a typo in the url or port?", + ), + ]) { + expect(isRetryable(unresolved)).toBe(true); + expect(isSafeToReplay(unresolved)).toBe(false); + } + }); + + it("retries a connection closed before the response ended, and does not replay it", () => { + // Bun gives Node's errno, `ECONNRESET`, for a reset and for a close, + // before the response or part-way through the body. The request may + // already have reached the server. + const reset = bunError( + "ECONNRESET", + "The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()", + ); + expect(isRetryable(reset)).toBe(true); + expect(isSafeToReplay(reset)).toBe(false); + }); + + it("retries a request that ran out of time, and does not replay it", () => { + // What bun's fetch rejects with when `AbortSignal.timeout()`, which + // bounds each JSON request, fires. + const timedOut = new DOMException( + "The operation timed out.", + "TimeoutError", + ); + expect(isRetryable(timedOut)).toBe(true); + expect(isSafeToReplay(timedOut)).toBe(false); + }); + + it("does not retry a certificate bun rejects", () => { + // A certificate that fails verification fails the same way however + // often it is asked. Under Node it is retried only because every + // `TypeError` is. + expect( + isRetryable( + bunError( + "DEPTH_ZERO_SELF_SIGNED_CERT", + "self signed certificate", + ), + ), + ).toBe(false); + }); +}); + describe("isRetryable: stream truncation versus corruption", () => { it("retries a truncated stream", () => { // Truncation is a transfer that stopped early. The bytes that did From 960a7ce8a621fef013a2a0535986158c3339d9f5 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 16 Oct 2026 05:23:27 +0200 Subject: [PATCH 33/39] An album whose folder cannot be made no longer stops the backup (closes #197) Each album's folder, links and JSON are written inside one try. A failure prints `FAILED album <name>: <reason>`, lists the album's ID in the new `failedAlbums` of the result and of `--json`, and makes `quak backup` exit 1; the other albums and `failures.json` are still written. An album whose folder would have another album's JSON name, ignoring case, gets its ID. A name over 255 bytes (250 for a folder, which leaves room for `.json`) gets the ID and is cut short between two characters by `shortenToBytes` in `src/filename.ts`. A link's ID goes right before its extension. Apart from its call to `uniqueNames`, the album loop is only indented into the try. Model: opus-5-5 --- README.md | 38 +++-- TODO.md | 12 +- src/backup.ts | 150 ++++++++++------- src/cli-commands.ts | 4 +- src/filename.ts | 20 ++- test/cli/backup-end-to-end.test.ts | 54 +++++-- test/cli/backup.test.ts | 250 ++++++++++++++++++++++++++++- test/filename/filename.test.ts | 29 +++- 8 files changed, 474 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index 5a26fe9..1674064 100644 --- a/README.md +++ b/README.md @@ -134,12 +134,12 @@ matters. ### Exit codes -| Code | Meaning | -| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | The backup is complete: every original is at its save path, and no file failed. | -| `1` | The run finished with files in `failures.json` or an album it could not decrypt, or it stopped on another error, printed as one line `quak: <message>`. The next run tries again. | -| `2` | Another backup of the same directory is running. This run sent no request and changed nothing. | -| `3` | There is no usable session: none is saved, the saved one is corrupt, or the server no longer accepts it. Run `quak login` as the user the cron job runs as. | +| Code | Meaning | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | The backup is complete: every original is at its save path, and no file failed. | +| `1` | The run finished with files in `failures.json` or albums it could not decrypt or write (see "Backup layout"), or it stopped on another error, printed as one line `quak: <message>`. The next run tries again. | +| `2` | Another backup of the same directory is running. This run sent no request and changed nothing. | +| `3` | There is no usable session: none is saved, the saved one is corrupt, or the server no longer accepts it. Run `quak login` as the user the cron job runs as. | `<message>` is the error's message, followed by the message of each error that caused it. Under Node, as `yarn quak` runs quak, `fetch` reports every failure @@ -827,8 +827,23 @@ after the file's title, both with unsafe characters replaced. When two collections would get the same name, or two symlinks in one collection the same name (ignoring case in both), each of them gets its ID added: two albums named `Trip` become `Trip (10)/` and `Trip (11)/`, and two files titled `IMG_0001.JPG` -become `IMG_0001 (12345).JPG` and `IMG_0001 (12346).JPG`. IDs never change, so a -name stays the same from run to run until such a clash appears or goes away. +become `IMG_0001 (12345).JPG` and `IMG_0001 (12346).JPG`. A collection whose +directory would have the name of another collection's JSON, again ignoring case, +gets its ID added as well: beside an album `x`, whose JSON is `x.json`, an album +named `x.json` gets the directory `x.json (12)/`. A name too long for a file +name, which most file systems limit to 255 bytes, gets its ID added too. A name +with its ID added is cut short between two characters in the part before the ID, +just far enough that it fits. When a symlink's extension alone leaves no room +for the ID, the extension is cut short and the part before the ID keeps only the +bytes that cut leaves over. A collection's directory name also leaves room for +the `.json` its JSON adds. IDs never change, so a name stays the same from run +to run until such a clash appears or goes away. + +A collection whose directory or JSON cannot be written, such as one whose +directory name is taken by a file you put in `collections/`, does not stop the +run. A run without `--json` prints `FAILED album <name>: <reason>` for it, +`--json` and the `BackupResult` list its ID in `failedAlbums`, and the exit code +is 1. The other collections and `failures.json` are still written. A live photo, which Ente stores as one ZIP of its image and its video, is stored as those two files, which a photo viewer can open: each is @@ -1122,9 +1137,10 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange` `downloadDirectory` (falls back to the library's), `includeOriginals` (default `true`), `includeThumbnails` (default `false`), `onlyAlbumNames`, `verify` (default `false`), `onProgress`, and `lock`. See Backup layout above for the - tree it writes. Whether the backup took the lock or its caller did, once the - lock is lost the backup rejects with `lost the lock on <dir>: <reason>` before - it starts another file. + tree it writes, and for the albums `BackupResult` lists in + `undecryptableAlbums` and `failedAlbums`. Whether the backup took the lock or + its caller did, once the lock is lost the backup rejects with + `lost the lock on <dir>: <reason>` before it starts another file. ### Request pools diff --git a/TODO.md b/TODO.md index a8e0a9d..c2dbf45 100644 --- a/TODO.md +++ b/TODO.md @@ -17,8 +17,7 @@ pre-1.0 # Next Step -Four bugs are open: https://git.eeqj.de/sneak/quak/issues/197, -https://git.eeqj.de/sneak/quak/issues/198, +Three bugs are open: https://git.eeqj.de/sneak/quak/issues/198, https://git.eeqj.de/sneak/quak/issues/199 and 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 @@ -42,6 +41,15 @@ declares one. `unknown`. The shapes were found by running the binary against local listeners. +- 2026-10-15: An album whose folder or JSON `quak backup` cannot write no longer + ends the run before the other albums and `failures.json` are written (issue + 197). The run prints `FAILED album <name>: <reason>`, lists the album's ID in + `failedAlbums` in the result and in `--json`, goes on, and exits 1. An album + whose folder would have the name of another album's JSON, as `x.json` beside + `x` would, gets its ID added. A folder or link name over 255 bytes is cut + short between two characters, by `shortenToBytes` in `src/filename.ts`, and + gets the ID. + - 2026-10-15: A network failure is printed and recorded with its reason, not as just `fetch failed` (issue 196). `describeError` in `src/errors.ts` gives an error's message followed by the message of each error on its `cause` chain, diff --git a/src/backup.ts b/src/backup.ts index 3c2b026..1c44dd4 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -50,7 +50,9 @@ // running attempt count, and the last-tried time, and the run continues. // A file the library could not decrypt is recorded there too, unless a copy of // it in another album decrypted; an album it could not decrypt is logged by ID -// and listed in `result.undecryptableAlbums`, as its name is not known. +// and listed in `result.undecryptableAlbums`, as its name is not known. An +// album whose folder or JSON cannot be written is logged by name and +// listed in `result.failedAlbums`, and the run goes on to the next album. // `result.failed` — and thus the CLI's exit code — stays non-zero while any // failure remains unresolved and clears once every one succeeds. Each run // reconciles the ledger against the files it attempted, so an entry for a file @@ -83,7 +85,12 @@ import { } from "./crypto/index.js"; import { removeLeftoverTempFiles } from "./download/index.js"; import { describeError } from "./errors.js"; -import { sanitizeFileName, withExtension } from "./filename.js"; +import { + MAX_NAME_BYTES, + sanitizeFileName, + shortenToBytes, + withExtension, +} from "./filename.js"; import { copyAtomic, placeOriginal, @@ -160,6 +167,9 @@ export interface BackupResult { // The IDs of the albums the library could not decrypt, whose files it // could not list; the CLI exits non-zero while there are any. undecryptableAlbums: number[]; + // The IDs of the albums whose folder or JSON this run could not write; + // the CLI exits non-zero while there are any. + failedAlbums: number[]; } // The slice of the library that backup drives. `Library` implements it; a test @@ -267,31 +277,47 @@ const rebuildSymlink = (linkPath: string, target: string): void => { // The on-disk names for the entries of one directory, in entry order. Each // name is used as is unless another entry would get the same name, ignoring // case (two names that differ only in case are one entry on a case-insensitive -// file system); then every entry sharing it gets ` (<id>)`, before the -// extension when `beforeExtension` is set. A name with an ID added can match -// another entry's own name (`IMG (6).JPG`), so this repeats until no name is -// shared. IDs are stable, so the names are too. +// 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 +// before the ID is cut short as far as it must be. When the extension alone +// leaves no room for the ID, the extension is cut short and the part before +// the ID keeps only the bytes that cut leaves over. With `withJSON`, each +// entry also has the JSON file `<name>.json` beside it, as an album does: a +// name then leaves room for `.json`, and one that is another entry's JSON name +// gets its ID added too. +// Two names with different IDs added never match, but one can match another +// entry's own name (`IMG (6).JPG`), so this repeats until no name is shared. +// IDs are stable, so the names are too. const uniqueNames = ( entries: { id: number; name: string }[], - beforeExtension: boolean, + opts: { beforeExtension?: boolean; withJSON?: boolean }, ): string[] => { + const maxBytes = MAX_NAME_BYTES - (opts.withJSON ? ".json".length : 0); const withID = (id: number, name: string): string => { - const ext = beforeExtension ? extname(name) : ""; - const stem = name.slice(0, name.length - ext.length); - return `${stem} (${id})${ext}`; + const tag = ` (${id})`; + const fullExt = opts.beforeExtension ? extname(name) : ""; + const stem = name.slice(0, name.length - fullExt.length); + 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 suffixed = new Set<number>(); for (;;) { const counts = new Map<string, number>(); - for (const name of names) { + const count = (name: string): void => { const key = name.toLowerCase(); counts.set(key, (counts.get(key) ?? 0) + 1); + }; + for (const name of names) { + count(name); + if (opts.withJSON) count(`${name}.json`); } let changed = false; for (const [i, { id, name }] of entries.entries()) { if (suffixed.has(i)) continue; - if (counts.get(name.toLowerCase()) === 1) continue; + const fits = Buffer.byteLength(name) <= maxBytes; + if (fits && counts.get(name.toLowerCase()) === 1) continue; names[i] = withID(id, name); suffixed.add(i); changed = true; @@ -841,7 +867,7 @@ const runLockedBackup = async ( id: c.id, name: sanitizeFileName(c.name, `collection-${c.id}`), })), - false, + { withJSON: true }, ); const albumDirNames = new Map( allCollections.map((c, i) => [c.id, dirNames[i]!]), @@ -857,60 +883,69 @@ const runLockedBackup = async ( log(`FAILED removing old album directories: ${describeError(err)}`); } + // An album whose folder or JSON cannot be written, such as one whose + // folder name is taken by a file the user put there, is logged and listed + // in the result, and the run goes on to the next album. + const failedAlbums: number[] = []; for (const c of collections) { if (lock.lost) throw lock.lost; - const colDirName = albumDirNames.get(c.id)!; - const colDir = join(collectionsDir, colDirName); - mkdirSync(colDir, { recursive: true }); - - // Every album links the one original, saved from the file's entry in - // `distinct`. - const files = filesByCollection.get(c.id) ?? []; - const links = files.flatMap((f) => - linksFor( - f, - storedAtSavePath(downloadDirectory, distinct.get(f.id)!), - ), - ); - const linkNames = uniqueNames(links, true); try { - removeStaleLinks(colDir, new Set(linkNames), downloadDirectory); - } catch (err) { - log( - `FAILED removing old links in ${c.name}: ${describeError(err)}`, - ); - } + const colDirName = albumDirNames.get(c.id)!; + const colDir = join(collectionsDir, colDirName); + mkdirSync(colDir, { recursive: true }); - const metaFiles = files.map((f) => ({ - id: f.id, - metadata: f.metadata, - })); - for (const [i, link] of links.entries()) { - if (!includeOriginals || link.target === undefined) continue; - const linkName = linkNames[i]!; + // Every album links the one original, saved from the file's entry + // in `distinct`. + const files = filesByCollection.get(c.id) ?? []; + const links = files.flatMap((f) => + linksFor( + f, + storedAtSavePath(downloadDirectory, distinct.get(f.id)!), + ), + ); + const linkNames = uniqueNames(links, { beforeExtension: true }); try { - rebuildSymlink( - join(colDir, linkName), - relative(colDir, link.target), - ); + removeStaleLinks(colDir, new Set(linkNames), downloadDirectory); } catch (err) { log( - `FAILED symlink ${c.name}/${linkName}: ${describeError(err)}`, - ); - recordFailure( - link.file.id, - link.file.metadata.title, - c.name, - err, + `FAILED removing old links in ${c.name}: ${describeError(err)}`, ); } - } - writeAlbumJSON( - join(collectionsDir, `${colDirName}.json`), - c, - metaFiles, - ); + const metaFiles = files.map((f) => ({ + id: f.id, + metadata: f.metadata, + })); + for (const [i, link] of links.entries()) { + if (!includeOriginals || link.target === undefined) continue; + const linkName = linkNames[i]!; + try { + rebuildSymlink( + join(colDir, linkName), + relative(colDir, link.target), + ); + } catch (err) { + log( + `FAILED symlink ${c.name}/${linkName}: ${describeError(err)}`, + ); + recordFailure( + link.file.id, + link.file.metadata.title, + c.name, + err, + ); + } + } + + writeAlbumJSON( + join(collectionsDir, `${colDirName}.json`), + c, + metaFiles, + ); + } catch (err) { + log(`FAILED album ${c.name}: ${describeError(err)}`); + failedAlbums.push(c.id); + } } // Reconcile the ledger against what this run actually attempted: an entry @@ -936,6 +971,7 @@ const runLockedBackup = async ( failed: ledger.size, errors, undecryptableAlbums, + failedAlbums, }; }; diff --git a/src/cli-commands.ts b/src/cli-commands.ts index 7a0c505..d545cf1 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -470,7 +470,9 @@ export const backupCommand = async ( } } - return result.failed > 0 || result.undecryptableAlbums.length > 0 + return result.failed > 0 || + result.undecryptableAlbums.length > 0 || + result.failedAlbums.length > 0 ? 1 : 0; } finally { diff --git a/src/filename.ts b/src/filename.ts index 1e6250b..e9781ca 100644 --- a/src/filename.ts +++ b/src/filename.ts @@ -4,7 +4,8 @@ // hands us, and quak does not trust the server. Any name taken from them and // used on disk goes through here, so it can only ever name one file inside the // directory the caller chose: never a path, never `..`, never hidden, never a -// Windows device name. +// Windows device name. Such a name can still be too long for a file name, and +// `shortenToBytes` cuts one short. // // A path the user typed (`--out`, `outPath`) is not passed through here: the // caller is trusted, the server is not. @@ -39,3 +40,20 @@ export const safeExtension = (title: string): string => { // `name` with its extension, if it has one, replaced by `ext` (".mov"). export const withExtension = (name: string, ext: string): string => name.slice(0, name.length - extname(name).length) + ext; + +// The longest file name, in bytes, that ext4 and most other file systems allow. +export const MAX_NAME_BYTES = 255; + +// `name` cut short to at most `maxBytes` bytes of UTF-8, the encoding a file +// name is written to disk in, between two characters, never inside one. A name +// that fits comes back unchanged. +export const shortenToBytes = (name: string, maxBytes: number): string => { + let bytes = 0; + let end = 0; + for (const character of name) { + bytes += Buffer.byteLength(character); + if (bytes > maxBytes) break; + end += character.length; + } + return name.slice(0, end); +}; diff --git a/test/cli/backup-end-to-end.test.ts b/test/cli/backup-end-to-end.test.ts index 1009cc0..8636a8f 100644 --- a/test/cli/backup-end-to-end.test.ts +++ b/test/cli/backup-end-to-end.test.ts @@ -274,17 +274,17 @@ afterAll(() => { // Run `quak backup <dir>` as `bin/quak.ts` runs it, sending its requests // through `fetch`, and return its exit code and everything it printed on -// stderr. Its requests are retried as those of `quak backup` are, except that -// a retry does not wait. A download is given up after `downloadTimeoutMs` with -// no bytes received, 60 seconds unless given. Its cache is beside `dir`, so -// each run of one directory finds the cache the run before it left, as cron's -// runs find the user's. +// stderr and on stdout. Its requests are retried as those of `quak backup` +// are, except that a retry does not wait. A download is given up after +// `downloadTimeoutMs` with no bytes received, 60 seconds unless given. Its +// cache is beside `dir`, so each run of one directory finds the cache the run +// before it left, as cron's runs find the user's. const backup = async ( dir: string, - opts: { verify?: boolean } = {}, + opts: { verify?: boolean; json?: boolean } = {}, fetch = server, downloadTimeoutMs?: number, -): Promise<{ code: number; stderr: string }> => { +): Promise<{ code: number; stderr: string; stdout: string }> => { let printed = ""; const stderr = new Writable({ write(chunk: Buffer, _encoding, done) { @@ -292,8 +292,10 @@ const backup = async ( done(); }, }); + let output = ""; const stdout = new Writable({ - write(_chunk, _encoding, done) { + write(chunk: Buffer, _encoding, done) { + output += chunk.toString(); done(); }, }); @@ -319,7 +321,7 @@ const backup = async ( const code = await new Promise<number>((resolve) => { void run(backupCommand(ctx, dir, opts), stdout, stderr, resolve); }); - return { code, stderr: printed }; + return { code, stderr: printed, stdout: output }; }; // The ID of the file whose original a request downloads, or undefined for any @@ -630,6 +632,40 @@ describe("quak backup", () => { ).toEqual([]); }); + // The user has put a file in collections/ with the name of the Trips + // folder, so quak cannot make that folder. It says so on one line, writes + // the two albums after it, and exits 1, and --json lists the album's ID + // in `failedAlbums`. No file failed, so there is no failures.json. + it("exits 1 when an album's folder cannot be made, writes the other albums, and lists it in --json", async () => { + const dir = mkdtempSync(join(root, "backup-")); + mkdirSync(join(dir, "collections")); + writeFileSync(join(dir, "collections", "Trips"), "mine"); + + const first = await backup(dir); + const { code, stdout } = await backup(dir, { json: true }); + + expect(first.code).toBe(1); + expect(first.stderr).toMatch(/^FAILED album Trips: EEXIST: /m); + expect(code).toBe(1); + expect(JSON.parse(stdout)).toMatchObject({ + failed: 0, + undecryptableAlbums: [], + failedAlbums: [10], + }); + const collections = join(dir, "collections"); + expect(readJSON(join(collections, "Family.json")).id).toBe(20); + expect(readdirSync(join(collections, "Family")).sort()).toEqual([ + "IMG_0002.heic", + "IMG_0002.mov", + "beach.jpg", + ]); + expect(readJSON(join(collections, "Friend's Wedding.json")).id).toBe( + 30, + ); + expect(existsSync(join(collections, "Trips.json"))).toBe(false); + expect(existsSync(join(dir, "failures.json"))).toBe(false); + }); + // The server no longer accepts the saved session's token, as after // `quak logout` elsewhere, and answers every request with 401. The run // stops at the refresh it starts with, says to log in again, and exits 3. diff --git a/test/cli/backup.test.ts b/test/cli/backup.test.ts index 1938a4b..09bedc6 100644 --- a/test/cli/backup.test.ts +++ b/test/cli/backup.test.ts @@ -1498,13 +1498,15 @@ describe("backup album folders", () => { files: EnteFile[]; } - const libraryOf = (albums: Album[]): BackupLibrary => ({ + // The original of the file `notFound`, if given, always fails to download. + const libraryOf = (albums: Album[], notFound?: number): BackupLibrary => ({ refresh: async () => {}, listCollections: () => albums.map((a) => a.collection), listFiles: (id) => albums.find((a) => a.collection.id === id)?.files ?? [], undecryptable: () => [], original: async (fileID) => { + if (fileID === notFound) throw new Error("HTTP 404"); const path = join(root, `source-${fileID}`); writeFileSync(path, `original ${fileID}`); return { path }; @@ -1600,6 +1602,252 @@ describe("backup album folders", () => { expect(albumID(outDir, "Trip (11) (12).json")).toBe(12); }); + // The next three tests end with the album "ok" (14), whose one file + // always fails to download, so each run has a failure to record in + // failures.json after the album folders are written. + const ok: Album = { + collection: collection(14, "ok"), + files: [file(5, 14, "e.jpg")], + }; + + // The folder of an album named "x.json" would be the JSON file of the + // album "x", and once ended every run there. Two runs, as on two nights, + // write the same tree, and the failed file's attempt count goes up. + it("adds the ID to an album whose folder would have another album's JSON name", async () => { + const outDir = join(root, "backup"); + const lib = libraryOf( + [ + { + collection: collection(10, "x"), + files: [file(1, 10, "a.jpg")], + }, + { + collection: collection(11, "x.json"), + files: [file(2, 11, "b.jpg")], + }, + { + collection: collection(12, "Trip"), + files: [file(3, 12, "c.jpg")], + }, + { + collection: collection(13, "TRIP.json"), + files: [file(4, 13, "d.jpg")], + }, + ok, + ], + 5, + ); + + await runBackup(lib, { downloadDirectory: outDir }); + const result = await runBackup(lib, { downloadDirectory: outDir }); + + expect(result.failedAlbums).toEqual([]); + expect(tree(outDir)).toEqual([ + "TRIP.json (13)/", + `TRIP.json (13)/d.jpg -> ${linkTo("4.jpg")}`, + "TRIP.json (13).json", + "Trip/", + `Trip/c.jpg -> ${linkTo("3.jpg")}`, + "Trip.json", + "ok/", + "ok.json", + "x/", + `x/a.jpg -> ${linkTo("1.jpg")}`, + "x.json", + "x.json (11)/", + `x.json (11)/b.jpg -> ${linkTo("2.jpg")}`, + "x.json (11).json", + ]); + expect(albumID(outDir, "x.json")).toBe(10); + expect(albumID(outDir, "x.json (11).json")).toBe(11); + expect(albumID(outDir, "TRIP.json (13).json")).toBe(13); + expect(albumID(outDir, "ok.json")).toBe(14); + expect(result.failed).toBe(1); + expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 2 }); + }); + + // A name over 255 bytes is refused as a file name, and an album folder's + // name leaves room for `.json` in its JSON's. "é" takes two bytes, and a + // name is cut between two characters. + it("cuts an album name and a title too long for a file name short and adds the ID", async () => { + const outDir = join(root, "backup"); + const lib = libraryOf( + [ + { + collection: collection(10, "L".repeat(300)), + files: [file(1, 10, `${"é".repeat(150)}.jpg`)], + }, + ok, + ], + 5, + ); + + await runBackup(lib, { downloadDirectory: outDir }); + const result = await runBackup(lib, { downloadDirectory: outDir }); + + const folder = `${"L".repeat(245)} (10)`; + const link = `${"é".repeat(123)} (1).jpg`; + expect(Buffer.byteLength(`${folder}.json`)).toBe(255); + expect(Buffer.byteLength(link)).toBe(254); + expect(result.failedAlbums).toEqual([]); + expect(tree(outDir)).toEqual([ + `${folder}/`, + `${folder}/${link} -> ${linkTo("1.jpg")}`, + `${folder}.json`, + "ok/", + "ok.json", + ]); + expect(albumID(outDir, `${folder}.json`)).toBe(10); + expect(albumID(outDir, "ok.json")).toBe(14); + expect(result.failed).toBe(1); + expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 2 }); + }); + + // The user has put a file in collections/ with the name of the album + // Trip's folder, so the folder cannot be made. + it("goes on to the next album when an album's folder cannot be made, and lists it as failed", async () => { + const outDir = join(root, "backup"); + const lib = libraryOf( + [ + { + collection: collection(10, "Trip"), + files: [file(1, 10, "a.jpg")], + }, + ok, + ], + 5, + ); + mkdirSync(join(outDir, "collections"), { recursive: true }); + writeFileSync(join(outDir, "collections", "Trip"), "mine"); + const logged: string[] = []; + + const result = await runBackup(lib, { + downloadDirectory: outDir, + onProgress: (message) => logged.push(message), + }); + + expect(result.failedAlbums).toEqual([10]); + expect(logged).toContainEqual( + expect.stringMatching(/^FAILED album Trip: EEXIST: /), + ); + expect(tree(outDir)).toEqual(["Trip", "ok/", "ok.json"]); + expect(albumID(outDir, "ok.json")).toBe(14); + expect(existsSync(saved(outDir, "1.jpg"))).toBe(true); + expect(result.failed).toBe(1); + expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 1 }); + }); + + // The extensions of these titles leave no room for the ID, so each is cut + // short and the name before the ID keeps only the bytes that cut leaves + // over. File 1's extension is 303 bytes and is cut to 251, which leaves + // none. File 2's is 271 bytes and is cut to 250, since "€" takes three + // bytes, which leaves one, for the "a". Neither extension is only letters + // and digits, so both originals are saved with `.bin`, a name that fits. + it("cuts the extension of a title short when it leaves no room for the ID", async () => { + const outDir = join(root, "backup"); + const lib = libraryOf([ + { + collection: collection(10, "Trip"), + files: [ + file(1, 10, `a.${"L".repeat(300)}-x`), + file(2, 10, `a.${"€".repeat(90)}`), + ], + }, + ]); + + const result = await runBackup(lib, { downloadDirectory: outDir }); + + const link = ` (1).${"L".repeat(250)}`; + const link2 = `a (2).${"€".repeat(83)}`; + expect(Buffer.byteLength(link)).toBe(255); + expect(Buffer.byteLength(link2)).toBe(255); + expect(result.failed).toBe(0); + expect(tree(outDir)).toEqual([ + "Trip/", + `Trip/${link} -> ${linkTo("1.bin")}`, + `Trip/${link2} -> ${linkTo("2.bin")}`, + "Trip.json", + ]); + }); + + // Files 1 and 3 share a title, so each gets its ID before the + // extension. File 2's title is too long, and its extension leaves no + // room for the ID. Were the ID put at the end of file 2's title cut + // short, its link would have file 1's name, and the link made second + // would replace the first. + it("keeps names unique when a title's extension leaves no room for the ID", async () => { + const outDir = join(root, "backup"); + const shared = `x.${"Q".repeat(245)} (2)`; + const lib = libraryOf([ + { + collection: collection(10, "Trip"), + files: [ + file(1, 10, shared), + file(2, 10, `x (1).${"Q".repeat(250)}-`), + file(3, 10, shared), + ], + }, + ]); + + const result = await runBackup(lib, { downloadDirectory: outDir }); + + expect(result.failed).toBe(0); + expect(tree(outDir)).toEqual([ + "Trip/", + `Trip/ (2).${"Q".repeat(250)} -> ${linkTo("2.bin")}`, + `Trip/x (1).${"Q".repeat(245)} (2) -> ${linkTo("1.bin")}`, + `Trip/x (3).${"Q".repeat(245)} (2) -> ${linkTo("3.bin")}`, + "Trip.json", + ]); + }); + + // A title of 255 bytes is the longest file name allowed, and an album + // name of 250 bytes the longest whose JSON name fits; both are kept as + // they are. A name one byte longer gets the ID and is cut short. "é" + // takes two bytes, and in both names the room left ends inside one, so + // the cut falls before it, one byte short of the room. + it("keeps a name at the byte limit and adds the ID to one a byte over it", async () => { + const outDir = join(root, "backup"); + const album = "A".repeat(250); + const title = `${"t".repeat(251)}.jpg`; + const longAlbum = `${"é".repeat(125)}B`; + const longTitle = `${"é".repeat(126)}.jpg`; + const lib = libraryOf([ + { + collection: collection(10, album), + files: [file(1, 10, title), file(2, 10, longTitle)], + }, + { + collection: collection(11, longAlbum), + files: [file(3, 11, "c.jpg")], + }, + ]); + + const result = await runBackup(lib, { downloadDirectory: outDir }); + + const folder = `${"é".repeat(122)} (11)`; + const link = `${"é".repeat(123)} (2).jpg`; + expect(Buffer.byteLength(`${album}.json`)).toBe(255); + expect(Buffer.byteLength(title)).toBe(255); + expect(Buffer.byteLength(longAlbum)).toBe(251); + expect(Buffer.byteLength(longTitle)).toBe(256); + expect(Buffer.byteLength(`${folder}.json`)).toBe(254); + expect(Buffer.byteLength(link)).toBe(254); + expect(result.failed).toBe(0); + expect(result.failedAlbums).toEqual([]); + expect(tree(outDir)).toEqual([ + `${album}/`, + `${album}/${title} -> ${linkTo("1.jpg")}`, + `${album}/${link} -> ${linkTo("2.jpg")}`, + `${album}.json`, + `${folder}/`, + `${folder}/c.jpg -> ${linkTo("3.jpg")}`, + `${folder}.json`, + ]); + expect(albumID(outDir, `${album}.json`)).toBe(10); + expect(albumID(outDir, `${folder}.json`)).toBe(11); + }); + it("changes nothing on a second run over an unchanged account", async () => { const outDir = join(root, "backup"); const lib = libraryOf([ diff --git a/test/filename/filename.test.ts b/test/filename/filename.test.ts index 81e7c56..4cfce05 100644 --- a/test/filename/filename.test.ts +++ b/test/filename/filename.test.ts @@ -14,7 +14,11 @@ import { describe, expect, it } from "vitest"; -import { safeExtension, sanitizeFileName } from "../../src/filename.js"; +import { + safeExtension, + sanitizeFileName, + shortenToBytes, +} from "../../src/filename.js"; const FALLBACK = "file-42"; @@ -91,3 +95,26 @@ describe("safeExtension", () => { expect(safeExtension("x.jpg\0")).toBe(".bin"); }); }); + +describe("shortenToBytes", () => { + it("returns a name that fits unchanged", () => { + expect(shortenToBytes("beach", 5)).toBe("beach"); + expect(shortenToBytes("café", 5)).toBe("café"); + expect(shortenToBytes("", 0)).toBe(""); + }); + + it("cuts a name to the number of bytes given", () => { + expect(shortenToBytes("L".repeat(300), 255)).toBe("L".repeat(255)); + }); + + // In UTF-8 "é" takes two bytes and an emoji four, which a JavaScript + // string holds as two halves. + it("cuts between two characters, never inside one", () => { + expect(shortenToBytes("ééé", 5)).toBe("éé"); + expect(shortenToBytes("a\u{1F600}b", 4)).toBe("a"); + expect(shortenToBytes("a\u{1F600}b", 5)).toBe("a\u{1F600}"); + expect(Buffer.byteLength(shortenToBytes("é".repeat(200), 255))).toBe( + 254, + ); + }); +}); From 8583863ec01dba050e5727286fc542199caf1ade Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 16 Oct 2026 06:56:19 +0200 Subject: [PATCH 34/39] --verify keeps a mismatched original until a matching copy replaces it (closes #199) With --verify, an original whose hash does not match is no longer removed before its replacement is downloaded. replaceOriginal, beside placeOriginal in src/library/content.ts, has the content cache's new fetchOriginal write the replacement to a temporary file beside the original, hashes it, and renames it over the original only when it matches; a live photo's image and video are both hashed before either is renamed. A failed or mismatching download leaves the old bytes and album links, removes the temporary files, and lands in failures.json. The replacement is always downloaded, never copied from the cache. The leftover-file cleanup also takes a live photo's replacement files, .quak-<pid>-<random>.<ext>. Model: opus-5-5 --- README.md | 65 ++++++++++++---------- TODO.md | 13 ++++- src/backup.ts | 92 ++++++++++++------------------- src/download/index.ts | 16 ++++-- src/library/content.ts | 118 +++++++++++++++++++++++++++++++++++++++- src/library/index.ts | 2 + test/cli/backup.test.ts | 103 ++++++++++++++++++++++++++++------- 7 files changed, 294 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index 1674064..a419eb5 100644 --- a/README.md +++ b/README.md @@ -159,10 +159,11 @@ 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 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 -(see "Backup layout"). An original that a `--verify` run could not read, or put -back with bytes that still do not match, stays at its save path. Only a later -`--verify` run checks it again: a run without `--verify` leaves it as it is and -takes the file out of `failures.json`, so that run can exit 0. +(see "Backup layout"). An original that a `--verify` run could not read, or +could not replace with a download that matches, stays at its save path as it +was. Only a later `--verify` run checks it again: a run without `--verify` +leaves it as it is and takes the file out of `failures.json`, so that run can +exit 0. ## Examples @@ -692,8 +693,8 @@ reads no tag from is recorded, base64, as `exifRaw`, with the reason in and `helper fix-missing-thumbnails` take `--json` for machine-readable output. `backup --verify` also hashes the originals already in the backup and replaces -any that do not match the content hash Ente records; one it cannot replace goes -into `failures.json` (see "Backup layout"). +any that do not match the content hash Ente records; one it cannot replace stays +as it was and goes into `failures.json` (see "Backup layout"). `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 @@ -878,33 +879,41 @@ original already at its save path the way a download is checked (see "On-disk cache layout" below): its bytes, read in chunks, or a live photo's image and 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 -line naming the file, deleted (a live photo's image and video both), and put -back in the same run like a missing one: downloaded, or copied from the cache if -the cache holds it. What is put back is hashed too, because a copy from the -cache is not checked as a download is. If it still does not match, it stays at -its save path and the file goes into `failures.json`, as it does when the -download fails. A file whose metadata records no hash is left as it is and -counted as unchecked. A stored original that cannot be read is left as it is and -counts as failed. The summary and `--json` add the counts `verified`, -`mismatched` and `unchecked`, all of originals that were already stored; one -first downloaded in this run is in none of them. A mismatch that was put back -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`. +line naming the file, and replaced once a matching copy is downloaded, in the +same run. The copy is downloaded even when the cache holds the original, because +a copy from the cache is not checked as a download is. It is written to a +temporary file beside the original, a live photo's image and video each to its +own, hashed the same way, and renamed over the original only when it matches; a +live photo's image and video are both hashed before either is renamed. Until +then the original stays as it was: when the download fails, or what it brings +does not match either, the temporary files are deleted, the original keeps its +bytes and its album links, and the file goes into `failures.json`. A run that +stops between renaming a live photo's new image and its new video leaves the new +image beside the old video, which a later `--verify` run replaces if the video +was the part that did not match. A file whose metadata records no hash is left +as it is and counted as unchecked. A stored original that cannot be read is left +as it is and counts as failed. The summary and `--json` add the counts +`verified`, `mismatched` and `unchecked`, all of originals that were already +stored; one first downloaded in this run is in none of them. A mismatch that was +replaced with matching bytes does not make the exit code non-zero. Without +`--verify` nothing is hashed, the summary is unchanged, and the three counts are +0 in `--json`. Each original is written to a temporary file in the same directory, synced to 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 `.quak-<pid>-<random>.tmp`, one copied from the cache -`.quak-backup-YYYY-MM-DD.<fileID>.<ext>-<pid>-<random>.tmp`. A run that is -killed can leave one of these temporary files behind; the next backup deletes -those whose process is no longer running. The content cache uses the same -scheme, and opening a library deletes the temporary files in the cache whose -process is no longer running, so a download another process has in progress in -the same cache is left alone. The rename replaces whatever was at the -destination rather than writing through it: a symlink there is replaced, not -followed, and the new file has the temporary file's permissions, not those of -the file it replaced. +`.quak-backup-YYYY-MM-DD.<fileID>.<ext>-<pid>-<random>.tmp`. A copy that +`--verify` downloads to replace an original is written to such a +`.quak-<pid>-<random>.tmp` too, a live photo's image and video to +`.quak-<pid>-<random>.<ext>`, each with its own extension. A run that is killed +can leave one of these temporary files behind; the next backup deletes those +whose process is no longer running. The content cache uses the same scheme, and +opening a library deletes the temporary files in the cache whose process is no +longer running, so a download another process has in progress in the same cache +is left alone. The rename replaces whatever was at the destination rather than +writing through it: a symlink there is replaced, not followed, and the new file +has the temporary file's permissions, not those of the file it replaced. ## TODO diff --git a/TODO.md b/TODO.md index c2dbf45..3850249 100644 --- a/TODO.md +++ b/TODO.md @@ -17,8 +17,7 @@ pre-1.0 # Next Step -Three bugs are open: https://git.eeqj.de/sneak/quak/issues/198, -https://git.eeqj.de/sneak/quak/issues/199 and +Two bugs are open: https://git.eeqj.de/sneak/quak/issues/198 and 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 has, so it waits on him. @@ -28,6 +27,16 @@ declares one. # Completed Steps +- 2026-10-16: With `--verify`, an original whose hash does not match is no + longer removed before its replacement is downloaded (issue 199). + `replaceOriginal` in `src/library/content.ts` downloads the replacement to a + temporary file beside it, a live photo's image and video each to its own, + hashes it and renames it over the original only when it matches. When the + download fails or does not match either, the original keeps its bytes and + links, and the file goes into `failures.json`. The replacement is always + downloaded, through the new `fetchOriginal` of the content cache, never copied + from the cache. + - 2026-10-16: The `~/bin/quak` that `make install` builds retries a refused connection and a name that does not resolve, and the backup records either as transient (issue 206). Bun's `fetch` reports them with codes of its own, diff --git a/src/backup.ts b/src/backup.ts index 1c44dd4..8dc9e4a 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -41,8 +41,9 @@ // // 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 -// metadata records is removed and fetched again in the same run. What is put -// back is hashed too, and recorded as failed if it still does not match. +// metadata records is replaced once a matching copy is downloaded, in the same +// run. Until then it stays as it is: a download that fails, or that does not +// match either, leaves it in place and is recorded as failed. // // Resilience (issue #8): no per-file condition aborts the run. A failed // download, a failed symlink, or ML data missing because the ML data fetch @@ -61,7 +62,6 @@ // code. import { - createReadStream, lstatSync, mkdirSync, readdirSync, @@ -77,12 +77,6 @@ import { readFile } from "node:fs/promises"; import { dirname, extname, join, relative, resolve } from "node:path"; import lockfile from "proper-lockfile"; -import { - chunkHashFinal, - chunkHashInit, - chunkHashUpdate, - init, -} from "./crypto/index.js"; import { removeLeftoverTempFiles } from "./download/index.js"; import { describeError } from "./errors.js"; import { @@ -94,8 +88,10 @@ import { import { copyAtomic, placeOriginal, + replaceOriginal, savePath, storedAtSavePath, + storedHash, } from "./library/content.js"; import { representative } from "./library/records.js"; import type { Undecryptable } from "./library/store.js"; @@ -152,9 +148,9 @@ export interface BackupResult { // Originals already at their save path and left untouched. skipped: number; // With `verify`, the originals already at their save path whose hash - // matched, those whose hash did not (each removed and fetched again), and - // those whose metadata records no hash (left as they are). All three are - // zero without `verify`. + // matched, those whose hash did not (each replaced once a matching copy is + // downloaded), and those whose metadata records no hash (left as they + // are). All three are zero without `verify`. verified: number; mismatched: number; unchecked: number; @@ -192,6 +188,15 @@ export interface BackupLibrary { fileID: number, destination: 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 }>; // How many originals the backup works on at once: the limit of the // library's content pool. @@ -444,26 +449,6 @@ const saveLedger = (path: string, ledger: Map<number, FailureEntry>): void => { ); }; -// The content hash of the original stored at `stored`, computed as the download -// check computes it: over each file's bytes, read in chunks, and for a live -// photo `<imageHash>:<videoHash>`. -const storedHash = async (stored: { - path: string; - videoPath?: string; -}): Promise<string> => { - await init(); - const hashFile = async (path: string): Promise<string> => { - const state = chunkHashInit(); - for await (const chunk of createReadStream(path)) { - chunkHashUpdate(state, chunk as Buffer); - } - return chunkHashFinal(state); - }; - const hash = await hashFile(stored.path); - if (stored.videoPath === undefined) return hash; - return `${hash}:${await hashFile(stored.videoPath)}`; -}; - // A file's EXIF, XMP and dimensions as its JSON holds them: what // `extractImageMetadata` found in its original, or why the original could not // be read. @@ -685,11 +670,13 @@ const runLockedBackup = async ( // through the content cache/pools, as `Photo.download()` does, and fetch // 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 - // its metadata records is removed and fetched like a missing one. One - // that cannot be read is recorded as failed and left where it is. - // `storeOriginal` does this for one original, and records its failure. + // its metadata records is downloaded again and replaced by + // `replaceOriginal`, which leaves it as it was when that download fails + // or does not match either. One that cannot be read is recorded as failed + // and left where it is. `storeOriginal` does this for one original, and + // records its failure. const storeOriginal = async (file: EnteFile): Promise<void> => { - let stored = storedAtSavePath(downloadDirectory, file); + const stored = storedAtSavePath(downloadDirectory, file); let mismatch = false; if (stored !== undefined && verify) { try { @@ -703,11 +690,6 @@ const runLockedBackup = async ( ); mismatched++; mismatch = true; - rmSync(stored.path); - if (stored.videoPath !== undefined) { - rmSync(stored.videoPath); - } - stored = undefined; } } catch (err) { log( @@ -722,29 +704,25 @@ const runLockedBackup = async ( return; } } - if (stored !== undefined) { + if (stored !== undefined && !mismatch) { skipped++; return; } try { log(`Fetching original ${file.metadata.title} (${file.id})...`); - // A fetched original is written straight to its save path (a - // live photo beside it); only one that was already cached - // elsewhere is copied. - const placed = await placeOriginal( - downloadDirectory, - file, - (dest) => lib.original(file.id, dest), - ); - storedThisRun.add(file.id); - // 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", + if (mismatch) { + await replaceOriginal(downloadDirectory, file, (dest) => + lib.fetchOriginal(file.id, dest), + ); + } else { + // A fetched original is written straight to its save path (a + // live photo beside it); only one that was already cached + // elsewhere is copied. + await placeOriginal(downloadDirectory, file, (dest) => + lib.original(file.id, dest), ); } + storedThisRun.add(file.id); downloaded++; } catch (err) { log( diff --git a/src/download/index.ts b/src/download/index.ts index 1e8b30a..59b510a 100644 --- a/src/download/index.ts +++ b/src/download/index.ts @@ -199,11 +199,13 @@ const isRunning = (pid: number): boolean => { }; // Delete the temp files a killed process left in `dir`: the writer's -// `.quak-<pid>-<random>.tmp` and the backup copy's -// `.quak-backup-<name>-<pid>-<random>.tmp`. Only files whose process is no -// longer running are removed, so another process writing into the same -// directory keeps its own. A reused process ID can only keep a leftover a while -// longer, never remove a live one. +// `.quak-<pid>-<random>.tmp`, the backup copy's +// `.quak-backup-<name>-<pid>-<random>.tmp`, and the image and video of a live +// photo downloaded to replace a stored one, `.quak-<pid>-<random>.<ext>` (see +// `replaceOriginal`). Only files whose process is no longer running are +// removed, so another process writing into the same directory keeps its own. A +// reused process ID can only keep a leftover a while longer, never remove a +// live one. export const removeLeftoverTempFiles = (dir: string): void => { let names: string[]; try { @@ -212,7 +214,9 @@ export const removeLeftoverTempFiles = (dir: string): void => { return; } for (const name of names) { - const match = /^\.quak-(?:.*-)?(\d+)-[0-9a-z]*\.tmp$/.exec(name); + const match = /^\.quak-(?:.*-)?(\d+)-[0-9a-z]*\.[0-9A-Za-z]+$/.exec( + name, + ); if (match && !isRunning(Number(match[1]))) { rmSync(join(dir, name), { force: true }); } diff --git a/src/library/content.ts b/src/library/content.ts index 383319b..9f2d6e6 100644 --- a/src/library/content.ts +++ b/src/library/content.ts @@ -13,6 +13,8 @@ // originals, the thumbnail pool for thumbnails. The pool limits concurrency, // 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. +// `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 // chunk is authenticated and the writer renames the file into place only once @@ -27,9 +29,10 @@ // It also names each original's save path under the download directory // (`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 -// fetched again. +// fetched again. An original stored there whose bytes do not match its content +// hash is replaced only by a download that does (`replaceOriginal`). -import { existsSync, readFileSync, statSync } from "node:fs"; +import { createReadStream, existsSync, readFileSync, statSync } from "node:fs"; import { chmod, copyFile, @@ -44,6 +47,12 @@ import { import { basename, dirname, extname, join } from "node:path"; import type { ApiClient } from "../api/client.js"; +import { + chunkHashFinal, + chunkHashInit, + chunkHashUpdate, + init, +} from "../crypto/index.js"; import { downloadFile, downloadThumbnail, @@ -349,6 +358,26 @@ export const storedAtSavePath = ( 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 // `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 @@ -405,6 +434,62 @@ export const placeOriginal = async ( 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 { private readonly pools: RequestPools; private readonly source: ContentSource; @@ -543,6 +628,35 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI { }; } + // Fetch an original through the content pool to `destination`, a live + // photo as its image and its video beside it, and return where it landed. + // Unlike `backupOriginal`, it fetches even when the cache or the save path + // already holds the original, and it records nothing: what it writes is + // the caller's. It is not shared with a fetch of the same file already + // under way, which writes somewhere else. + async fetchOriginal( + fileID: number, + destination: string, + ): Promise<{ path: string; videoPath?: string }> { + const file = this.getFile(fileID); + if (!file) throw new Error(`content cache: unknown file ${fileID}`); + return this.pools.content.run( + async () => { + const fetched = await this.fetchInto( + file, + destination, + "original", + undefined, + ); + for (const path of [fetched.path, fetched.videoPath]) { + if (path !== undefined) await chmod(path, FILE_MODE); + } + return fetched; + }, + { priority: "on-demand" }, + ); + } + async ensure(args: EnsureOptions): Promise<EnsureResult[]> { return this.ensureThumbnails(args); } diff --git a/src/library/index.ts b/src/library/index.ts index 95d8e1d..61fa734 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -606,6 +606,8 @@ export class Library { undecryptable: () => this.store.listUndecryptable(), original: (fileID, destination) => cache!.backupOriginal(fileID, destination), + fetchOriginal: (fileID, destination) => + cache!.fetchOriginal(fileID, destination), thumbnail: (fileID) => cache!.thumbnail(fileID), contentConcurrency: this.pools.content.concurrency, fetchMLData: () => this.fetchMLDataNow(), diff --git a/test/cli/backup.test.ts b/test/cli/backup.test.ts index 09bedc6..7cbb402 100644 --- a/test/cli/backup.test.ts +++ b/test/cli/backup.test.ts @@ -191,9 +191,12 @@ class HostileClient extends MockClient { } // A content source that writes byte buffers of the expected length and can be -// told to fail one fileID's original, to exercise per-file resilience. +// told to fail one fileID's original, as a server error or as a 404, or to +// write other bytes for it, to exercise per-file resilience. interface StubSource extends ContentSource { failID?: number; + notFoundID?: number; + otherBytesID?: number; failThumbID?: number; originalCalls: number; } @@ -204,8 +207,10 @@ const stubSource = (): StubSource => { original: async ({ file: f, destination }) => { s.originalCalls++; 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; - writeFileSync(destination, Buffer.alloc(size)); + const fill = s.otherBytesID === f.id ? 2 : 0; + writeFileSync(destination, Buffer.alloc(size, fill)); return { bytesWritten: size }; }, thumbnail: async ({ file: f, destination }) => { @@ -696,17 +701,26 @@ describe("lib.backup", () => { // A child that has already exited: its process ID is not running. const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid; 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 // time. const inProgress = `.quak-backup-2026-03-01.101.jpg-${process.pid}-def456.tmp`; - writeFileSync(join(day, leftover), "partial"); - writeFileSync(join(day, inProgress), "partial"); + for (const name of [leftover, ...replacement, inProgress]) { + writeFileSync(join(day, name), "partial"); + } const lib = await openLibrary(stubSource()); await lib.backup({ downloadDirectory: outDir }); const names = readdirSync(day); expect(names).not.toContain(leftover); + expect(names).not.toContain(replacement[0]); + expect(names).not.toContain(replacement[1]); expect(names).toContain(inProgress); lib.close(); }); @@ -1294,7 +1308,8 @@ describe("image metadata in each file's JSON", () => { }); // With `verify`, each original already stored is hashed, and one that does not -// match the content hash its metadata records is downloaded again. +// match the content hash its metadata records is downloaded again, and replaced +// only by a download that matches. describe("backup with verify", () => { // MockClient's files, each recording the hash of what `stubSource` writes // for it, except diagram.png (200), which records none. @@ -1337,6 +1352,12 @@ describe("backup with verify", () => { const good = Buffer.alloc(SIZE_BY_ID[100]!); 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 () => { const { lib, source, outDir } = await backedUp(); const calls = source.originalCalls; @@ -1381,13 +1402,16 @@ describe("backup with verify", () => { expect(log.filter((msg) => msg.startsWith("MISMATCH"))).toEqual([ "MISMATCH original beach.jpg (100): its bytes do not match its content hash", ]); + expect(tempFiles(outDir)).toEqual([]); await lib.close(); }); - it("records a failed download in failures.json, with the original removed", async () => { + // A copy whose hash does not match is still mostly the photo, so the + // backup keeps it until it holds a better one. + it("keeps an original that does not match its hash when its download answers 404", async () => { const { lib, source, outDir } = await backedUp(); writeFileSync(saved(outDir, "100.jpg"), corrupt); - source.failID = 100; + source.notFoundID = 100; const result = await lib.backup({ downloadDirectory: outDir, @@ -1400,18 +1424,18 @@ describe("backup with verify", () => { failed: 1, }); expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]); - expect(existsSync(saved(outDir, "100.jpg"))).toBe(false); + expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(corrupt); + expect( + readlinkSync(join(outDir, "collections", "Vacation", "beach.jpg")), + ).toBe(linkTo("100.jpg")); + expect(tempFiles(outDir)).toEqual([]); await lib.close(); }); - it("records in failures.json an original the cache puts back with the same bad bytes", async () => { - const lib = await openLibrary(stubSource(), 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 }); + it("keeps an original that does not match its hash when its download does not match either", async () => { + const { lib, source, outDir } = await backedUp(); + writeFileSync(saved(outDir, "100.jpg"), corrupt); + source.otherBytesID = 100; const result = await lib.backup({ downloadDirectory: outDir, @@ -1424,10 +1448,39 @@ describe("backup with verify", () => { failed: 1, }); expect(result.errors.map((e) => e.error)).toEqual([ - "the original put back does not match its content hash either", + "the downloaded original does not match its content hash either", ]); expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]); expect(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(); }); @@ -1511,6 +1564,9 @@ describe("backup album folders", () => { writeFileSync(path, `original ${fileID}`); return { path }; }, + fetchOriginal: async () => { + throw new Error("no verify in this stand-in"); + }, thumbnail: async () => { throw new Error("no thumbnails in this stand-in"); }, @@ -2244,11 +2300,12 @@ describe("backup of live photos", () => { Buffer.from(IMAGE), ); expect(readFileSync(video)).toEqual(Buffer.from(VIDEO)); + expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored); expect(tree(outDir)).toEqual(linked); await lib.close(); }); - it("removes both files of a live photo that does not match its hash when downloading it again fails", async () => { + it("keeps both files of a live photo that does not match its hash when downloading it again fails", async () => { const { file: live, body } = await asLivePhoto( file(500, 10, "IMG_0500.HEIC"), ); @@ -2266,8 +2323,14 @@ describe("backup of live photos", () => { expect(result).toMatchObject({ mismatched: 1, failed: 1 }); expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]); - expect(existsSync(saved(outDir, "500.heic"))).toBe(false); - expect(existsSync(saved(outDir, "500.mov"))).toBe(false); + expect(readFileSync(saved(outDir, "500.heic"))).toEqual( + Buffer.from(IMAGE), + ); + expect(readFileSync(saved(outDir, "500.mov"), "utf-8")).toBe( + "another few seconds of video", + ); + expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored); + expect(tree(outDir)).toEqual(linked); await lib.close(); }); From 7ec71bf7ec64e7bf877f1aa35440d86ccae06685 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 16 Oct 2026 07:23:16 +0200 Subject: [PATCH 35/39] ML data that does not decrypt is recorded with its reason (closes #198) `fetchMLDataBatch` dropped an entry that did not decrypt, gunzip or parse, so the file looked like one with no ML data and the run exited 0. It now returns the reason beside the payloads, also for ML data that is not a JSON object. The library gives the reasons from its last ML data fetch that succeeded in `status().mlDataErrors` and the `fetchMLData` "done" event; a fetch that fails leaves them as they were. `quak backup` writes the reason as `mlDataError` and records the file in `failures.json`; `backup-metadata` writes `mlDataError` and exits 1. Judgement call: a file whose ML data is cached keeps it in its JSON when a newer copy does not decode, as when the fetch fails. Model: opus-5-5 --- README.md | 25 +++- TODO.md | 20 +++- src/backup.ts | 28 +++-- src/cli-commands.ts | 4 +- src/client.ts | 12 +- src/library/index.ts | 64 ++++++++-- src/metadata-backup.ts | 22 ++-- src/mldata-fetch.ts | 57 +++++---- test/cli/backup-end-to-end.test.ts | 129 +++++++++++++++++++- test/cli/backup.test.ts | 14 ++- test/cli/metadata-backup.test.ts | 57 ++++++++- test/library/mldata.test.ts | 159 +++++++++++++++++++++++-- test/mldata-fetch/mldata-fetch.test.ts | 54 +++++++++ 13 files changed, 558 insertions(+), 87 deletions(-) create mode 100644 test/mldata-fetch/mldata-fetch.test.ts diff --git a/README.md b/README.md index a419eb5..4ff51c2 100644 --- a/README.md +++ b/README.md @@ -698,8 +698,10 @@ as it was and goes into `failures.json` (see "Backup layout"). `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 -`mlDataError` field instead of `mlData`, and the dump goes on. The exit code is -non-zero if any ML data request failed. +`mlDataError` field instead of `mlData`, and the dump goes on. A file whose ML +data does not decrypt, gunzip or parse is logged and written the same way, with +why in `mlDataError`. The exit code is non-zero if any file was written with +`mlDataError`. `helper fix-missing-thumbnails` regenerates thumbnails for JPEG images only, because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG image @@ -764,8 +766,15 @@ A file's JSON holds Ente's ML data for it (its faces and its CLIP embedding) as for has no `mlData`. The backup waits for the library's ML data fetch to finish before it writes the JSON files. If that fetch fails, each file whose ML data is not in the cache gets the reason in `mlDataError` instead and counts as failed, -and the next run fetches it again. The JSON files are rewritten on every run, so -ML data that arrived since the last run appears. +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 +parse. Its `mlDataError` says why, and `failures.json` records it with an error +starting `ML data: `. quak keeps that reason in memory only and caches nothing +for the file, so the next run asks for its ML data again. An ML data fetch that +fails, such as one the library's refresh starts while the backup writes the JSON +files, leaves the reasons as the last fetch that succeeded left them. The JSON +files are rewritten on every run, so ML data that arrived since the last run +appears. A file's JSON holds, as `imageMetadata`, what `backup-metadata --exif` records from its original, or from a live photo's image: `format`, `width` and `height` @@ -987,6 +996,14 @@ resolves once that refresh (including its cache write), the ML data fetch and the precache fetches already running have all finished, so the cache directory can then be removed. +`lib.status().mlDataErrors` lists, as `{ fileID, error }`, each file whose ML +data the last ML data fetch that succeeded got but could not decrypt, gunzip or +parse; the `fetchMLData` event with status `"done"` carries the same list. A +fetch that is still running leaves the list as it is until it ends, and a fetch +that fails leaves it as it was. Such ML data is not cached, so the next ML data +fetch asks for it again. The reasons are kept in memory only, never in the +cache. + ### Default reads vs. fresh reads Default reads — `lib.albums`, `lib.photos`, `lib.timeline` — answer diff --git a/TODO.md b/TODO.md index 3850249..840b2bb 100644 --- a/TODO.md +++ b/TODO.md @@ -17,16 +17,28 @@ pre-1.0 # Next Step -Two bugs are open: https://git.eeqj.de/sneak/quak/issues/198 and -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 -has, so it waits on him. +One bug is open: 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 has, so it waits on him. Tagging and releases are decided by sneak alone, and happen only when he declares one. # Completed Steps +- 2026-10-16: ML data the server sends that does not decrypt, gunzip or parse is + no longer taken for no ML data (issue 198). `fetchMLDataBatch` returns why for + each such file beside the payloads; ML data that parses to something other + than a JSON object, such as `null`, is one of them. The library keeps the + reasons from its last ML data fetch that succeeded in memory, not in the + cache, and gives them in `status().mlDataErrors` and in the `fetchMLData` + event with status `"done"`; a fetch still running or one that fails does not + change them. `quak backup` writes the reason as `mlDataError` in the file's + JSON when no ML data is cached for it, and records the file in `failures.json` + with an error starting `ML data: `; `backup-metadata` writes `mlDataError` + too. Both exit 1. Tests serve ML data encrypted under a key that is not the + file's. + - 2026-10-16: With `--verify`, an original whose hash does not match is no longer removed before its replacement is downloaded (issue 199). `replaceOriginal` in `src/library/content.ts` downloads the replacement to a diff --git a/src/backup.ts b/src/backup.ts index 8dc9e4a..32a9412 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -208,6 +208,11 @@ export interface BackupLibrary { // A file's cached ML data, as `lib.mldata.forFile()` returns it, or // undefined when none is cached. mlData(fileID: number): Promise<MLData | undefined>; + // Why the ML data the last ML data fetch that succeeded got for a file did + // not decrypt, gunzip or parse, or undefined when it did or the fetch got + // none. A fetch still running does not change it. A fetch that fails + // leaves it as it was. + mlDataError(fileID: number): unknown; } type FailureClass = "transient" | "permanent" | "unknown"; @@ -791,19 +796,18 @@ const runLockedBackup = async ( // Phase 2: rebuild the derived views from the model. Sidecars first, for // every present original (this repairs stale ones), each with the file's // ML data once an ML data fetch has completed, and its image metadata. - // When the fetch fails, a file with no cached ML data gets the reason - // instead and is recorded as failed. The next run fetches its ML data - // again because none is cached. + // When the fetch got a file's ML data but could not decrypt, gunzip or + // parse it, or when the fetch failed, a file with no cached ML data gets + // the reason instead and is recorded as failed. The next run fetches its + // ML data again because none is cached. if (includeOriginals) { let mlDataFailure: unknown; - let mlDataError: string | undefined; try { log("Fetching ML data..."); await lib.fetchMLData(); } catch (err) { mlDataFailure = err; - mlDataError = describeError(err); - log(`FAILED ML data: ${mlDataError}`); + log(`FAILED ML data: ${describeError(err)}`); } for (const file of distinct.values()) { if (lock.lost) throw lock.lost; @@ -820,13 +824,19 @@ const runLockedBackup = async ( storedThisRun.has(file.id), ); const mlData = await lib.mlData(file.id); - if (mlData === undefined && mlDataError !== undefined) { - writeSidecar(path, file, { mlDataError }, image); + const failure = lib.mlDataError(file.id) ?? mlDataFailure; + if (mlData === undefined && failure !== undefined) { + writeSidecar( + path, + file, + { mlDataError: describeError(failure) }, + image, + ); recordFailure( file.id, file.metadata.title, collectionName.get(file.collectionID) ?? "", - new Error("ML data", { cause: mlDataFailure }), + new Error("ML data", { cause: failure }), ); } else { writeSidecar(path, file, { mlData }, image); diff --git a/src/cli-commands.ts b/src/cli-commands.ts index d545cf1..a6d8b8a 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -394,11 +394,11 @@ export const backupMetadataCommand = async ( // Refresh first so the dump holds current account state, not what the // cache last held; a failed refresh throws. await lib.fresh(); - const { failedMLBatches } = await runMetadataBackup(lib, client, dir, { + const result = await runMetadataBackup(lib, client, dir, { exif: opts.exif || opts.all, onProgress: (msg) => ctx.stderr.write(msg + "\n"), }); - return failedMLBatches > 0 ? 1 : 0; + return result.filesWithMLDataError > 0 ? 1 : 0; } finally { await lib.close(); } diff --git a/src/client.ts b/src/client.ts index bcb3dac..2f5ea8d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -373,13 +373,17 @@ export class Client { } // Fetch machine-learning data (face detections + CLIP embeddings) for up - // to a batch of files, each decrypted with its own key. One request; the - // library batches at `MLDATA_BATCH_SIZE` and schedules each batch through - // its metadata request pool. + // to a batch of files, each decrypted with its own key, and for each file + // whose ML data did not decrypt, gunzip or parse, why not. One request; + // the library batches at `MLDATA_BATCH_SIZE` and schedules each batch + // through its metadata request pool. async fetchMLData(args: { fileIDs: number[]; fileKeys: Map<number, Uint8Array>; - }): Promise<Map<number, MLData>> { + }): Promise<{ + payloads: Map<number, MLData>; + errors: Map<number, unknown>; + }> { this.assertLoggedIn(); return fetchMLDataBatch(this.api, args.fileIDs, args.fileKeys); } diff --git a/src/library/index.ts b/src/library/index.ts index 61fa734..be62a1b 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -89,6 +89,7 @@ export { } from "./content.js"; export { type MLDataAPI, type SimilarResult } from "./mlsearch.js"; import type { CollectionsPage, FilesPage } from "../client.js"; +import { describeError } from "../errors.js"; import { MLDATA_BATCH_SIZE, type MLData } from "../mldata-fetch.js"; import type { Collection, EnteFile } from "../model/types.js"; import { runBackup, type BackupOptions, type BackupResult } from "../backup.js"; @@ -138,12 +139,16 @@ export interface LibraryClient { sinceTime: number; }): Promise<FilesPage>; // Fetch ML data (face detections + CLIP embeddings) for up to a batch of - // files. Optional: a client without it simply disables ML fetching, leaving - // the metadata refresh untouched. + // files, and for each file whose ML data did not decrypt, gunzip or parse, + // why not. Optional: a client without it simply disables ML fetching, + // leaving the metadata refresh untouched. fetchMLData?(args: { fileIDs: number[]; fileKeys: Map<number, Uint8Array>; - }): Promise<Map<number, MLData>>; + }): Promise<{ + payloads: Map<number, MLData>; + errors: Map<number, unknown>; + }>; // The byte source for the on-disk content cache. Optional so a mock client // that only serves metadata still satisfies the interface; when absent (and // no explicit `contentSource` is passed to `open`) the content cache is @@ -154,9 +159,11 @@ export interface LibraryClient { // A progress event for one unit of background work. A metadata "refresh" or an // ML "fetchMLData" pass each fire "started" before their network work and then // exactly one of "done" or "failed"; "failed" carries the error message and -// an ML "done" reports how many payloads it stored. The precache fills -// ("precacheThumbnails"/"precacheOriginals", #48) fire "started"/"done" around -// each sweep that has work, "done" reporting the count newly cached. +// 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. +// The precache fills ("precacheThumbnails"/"precacheOriginals", #48) fire +// "started"/"done" around each sweep that has work, "done" reporting the count +// newly cached. export interface RefreshEvent { operation: | "refresh" @@ -166,6 +173,7 @@ export interface RefreshEvent { status: "started" | "done" | "failed"; error?: string; fetched?: number; + mlDataErrors?: { fileID: number; error: string }[]; } export type RefreshProgressCallback = (event: RefreshEvent) => void; @@ -225,6 +233,11 @@ export interface LibraryStatus { lastMLFetchAt?: number; // The most recent ML fetch pass's error, set only while it failed. lastMLError?: string; + // Each file whose ML data the last ML fetch pass that succeeded got but + // could not decrypt, gunzip or parse, with the reason. A pass still + // running leaves it as it is. A pass that fails leaves it as it was. Such + // ML data is not cached, so the next pass asks for it again. + mlDataErrors: { fileID: number; error: string }[]; // ML payloads stored on disk and CLIP embeddings in the index; undefined // when ML fetching is disabled. mlStored?: number; @@ -292,6 +305,12 @@ export class Library { private lastError?: string; private lastMLFetchAt?: number; private lastMLError?: string; + // For each file whose ML data the last ML fetch pass that succeeded got + // but could not decrypt, gunzip or parse, why not, by fileID. A pass + // gathers its own and puts them here when it ends without failing, so + // `status()` and `backup()` never see the list of a pass that is still + // running or that failed. Kept in RAM only, never in the cache. + private mlDataErrors = new Map<number, unknown>(); // The plain-record projection as of the last refresh, and the GUI change // subscribers. A refresh that alters the projection notifies each with the // delta; `lastRecords` is kept current every refresh so a subscriber that @@ -542,6 +561,7 @@ export class Library { lastError: this.lastError, lastMLFetchAt: this.lastMLFetchAt, lastMLError: this.lastMLError, + mlDataErrors: this.describeMLDataErrors(), mlStored: ml?.stored, mlIndexed: ml?.indexed, originalsUsedBytes: originals?.usedBytes, @@ -612,6 +632,7 @@ export class Library { contentConcurrency: this.pools.content.concurrency, fetchMLData: () => this.fetchMLDataNow(), mlData: (fileID) => this.mldata.forFile({ fileID }), + mlDataError: (fileID) => this.mlDataErrors.get(fileID), }, { ...opts, downloadDirectory }, ); @@ -838,8 +859,11 @@ export class Library { // One ML fetch pass: fetch, decrypt and store the ML data for every file // the store knows about that is not cached (or whose `updationTime` has - // advanced), through the metadata pool, and update the CLIP index. A - // failure is reported through `status()` and `onProgress`, then thrown. + // 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 + // reason instead, and puts the reasons it gathered in `mlDataErrors` when + // it ends without failing. A failure leaves `mlDataErrors` as it was and + // is reported through `status()` and `onProgress`, then thrown. private async runMLFetch(): Promise<void> { const mldata = this.mlStore; // Bind so the call keeps the client as its receiver when invoked @@ -849,7 +873,14 @@ export class Library { const files = this.uniqueFiles(); const needed = mldata.neededFor(files); - if (needed.length === 0) return; + // This pass's reasons, put in `this.mlDataErrors` when it ends without + // failing. A file whose ML data did not decode in the pass before is + // still among `needed`, so this pass gives its reason anew. + const mlDataErrors = new Map<number, unknown>(); + if (needed.length === 0) { + this.mlDataErrors = mlDataErrors; + return; + } this.emit({ operation: "fetchMLData", status: "started" }); try { @@ -864,20 +895,25 @@ export class Library { for (let i = 0; i < needed.length; i += MLDATA_BATCH_SIZE) { if (this.closed) break; const batch = needed.slice(i, i + MLDATA_BATCH_SIZE); - const payloads = await this.pools.metadata.run( + const { payloads, errors } = await this.pools.metadata.run( () => fetchMLData({ fileIDs: batch, fileKeys }), { priority: "background" }, ); + for (const [id, error] of errors) { + mlDataErrors.set(id, error); + } stored += (await mldata.storeFetched(payloads, updation)) .stored; } + this.mlDataErrors = mlDataErrors; this.lastMLFetchAt = Date.now(); this.lastMLError = undefined; this.emit({ operation: "fetchMLData", status: "done", fetched: stored, + mlDataErrors: this.describeMLDataErrors(), }); } catch (err) { const error = err instanceof Error ? err.message : String(err); @@ -887,6 +923,14 @@ export class Library { } } + // `mlDataErrors` as `status()` and the ML "done" event give it. + private describeMLDataErrors(): { fileID: number; error: string }[] { + return [...this.mlDataErrors].map(([fileID, err]) => ({ + fileID, + error: describeError(err), + })); + } + // The distinct files the store holds, one entry per fileID (a file in // several collections shares its ML data), each carrying the key and the // newest `updationTime` seen across its memberships. diff --git a/src/metadata-backup.ts b/src/metadata-backup.ts index d453bf8..79fc4ed 100644 --- a/src/metadata-backup.ts +++ b/src/metadata-backup.ts @@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import * as jpeg from "jpeg-js"; import type { Client } from "./client.js"; +import { describeError } from "./errors.js"; import { readExifTags } from "./exif.js"; import type { Library, Photo } from "./library/index.js"; import { sanitizeFileName } from "./filename.js"; @@ -91,14 +92,15 @@ const extractExif = async ( // of plain JSON: account, per-collection, and per-file records including the // private and public magic metadata and (by default) the ML data. Collections // and files are enumerated from the library's cache, which the caller refreshes -// first. Returns how many ML data requests failed; their files are still +// first. Returns how many files have no ML data because their ML data request +// failed or their ML data did not decrypt, gunzip or parse; they are still // written, with `mlDataError` in place of `mlData`. export const runMetadataBackup = async ( lib: Library, client: Client, outDir: string, opts?: MetadataBackupOptions, -): Promise<{ failedMLBatches: number }> => { +): Promise<{ filesWithMLDataError: number }> => { const log = opts?.onProgress ?? (() => {}); const wantExif = opts?.exif ?? false; @@ -164,24 +166,28 @@ export const runMetadataBackup = async ( } // One failed request (retries exhausted) must not end the dump: its files - // get the reason in `mlDataError` and the other batches go on. + // get the reason in `mlDataError` and the other batches go on. So does a + // file whose ML data does not decrypt, gunzip or parse. log("Fetching ML data (face detections, CLIP embeddings)..."); const mlDataMap = new Map<number, MLData>(); const mlDataErrors = new Map<number, string>(); - let failedMLBatches = 0; const fileIDs = [...fileKeys.keys()]; for (let i = 0; i < fileIDs.length; i += MLDATA_BATCH_SIZE) { const batch = fileIDs.slice(i, i + MLDATA_BATCH_SIZE); try { - const result = await fetchMLDataBatch( + const { payloads, errors } = await fetchMLDataBatch( client.getApiClient(), batch, fileKeys, ); - for (const [id, payload] of result) mlDataMap.set(id, payload); + for (const [id, payload] of payloads) mlDataMap.set(id, payload); + for (const [id, err] of errors) { + const reason = describeError(err); + log(`ML data of file ${id} could not be read: ${reason}`); + mlDataErrors.set(id, reason); + } } catch (err) { const reason = err instanceof Error ? err.message : String(err); - failedMLBatches++; log( `ML data request for ${batch.length} file(s) failed: ${reason}`, ); @@ -229,5 +235,5 @@ export const runMetadataBackup = async ( } log("Metadata backup complete."); - return { failedMLBatches }; + return { filesWithMLDataError: mlDataErrors.size }; }; diff --git a/src/mldata-fetch.ts b/src/mldata-fetch.ts index e5a254d..7cca0c0 100644 --- a/src/mldata-fetch.ts +++ b/src/mldata-fetch.ts @@ -28,43 +28,52 @@ interface RawRemoteFileData { updatedAt?: number; } -// Decrypt one entry with its file key and gunzip the JSON payload. Returns -// undefined when the key is unknown or the entry does not decrypt/parse, so one -// corrupt file never fails a whole batch. -const decodeEntry = ( - entry: RawRemoteFileData, - key: Uint8Array | undefined, -): MLData | undefined => { - if (!key) return undefined; - try { - const decrypted = decryptBlob( - fromBase64(entry.encryptedData), - fromBase64(entry.decryptionHeader), - key, - ); - const json = gunzipSync(Buffer.from(decrypted)).toString("utf-8"); - return JSON.parse(json) as MLData; - } catch { - return undefined; +// Decrypt one entry with its file key and gunzip the JSON payload. Throws why +// when the entry does not decrypt, gunzip or parse, or parses to something +// other than a JSON object, such as `null`, a number or an array. +const decodeEntry = (entry: RawRemoteFileData, key: Uint8Array): MLData => { + const decrypted = decryptBlob( + fromBase64(entry.encryptedData), + fromBase64(entry.decryptionHeader), + key, + ); + const json = gunzipSync(Buffer.from(decrypted)).toString("utf-8"); + const payload: unknown = JSON.parse(json); + if ( + typeof payload !== "object" || + payload === null || + Array.isArray(payload) + ) { + throw new Error("not a JSON object"); } + return payload as MLData; }; // Fetch ML data for up to `MLDATA_BATCH_SIZE` ids in a single request. This is // the unit the request pools schedule; callers with more ids split them into -// batches and submit each batch to the pool. +// batches and submit each batch to the pool. Returns the payload of each file +// whose entry decodes and, in `errors`, why not for each file whose entry does +// not, so one corrupt file never fails a whole batch. An entry for a file +// whose key is not known, which quak did not ask for, is skipped. export const fetchMLDataBatch = async ( api: ApiClient, fileIDs: number[], fileKeys: Map<number, Uint8Array>, -): Promise<Map<number, MLData>> => { +): Promise<{ payloads: Map<number, MLData>; errors: Map<number, unknown> }> => { const { data } = await api.postJSON<{ data: RawRemoteFileData[] }>( "/files/data/fetch", { type: "mldata", fileIDs }, ); - const result = new Map<number, MLData>(); + const payloads = new Map<number, MLData>(); + const errors = new Map<number, unknown>(); for (const entry of data ?? []) { - const payload = decodeEntry(entry, fileKeys.get(entry.fileID)); - if (payload) result.set(entry.fileID, payload); + const key = fileKeys.get(entry.fileID); + if (!key) continue; + try { + payloads.set(entry.fileID, decodeEntry(entry, key)); + } catch (err) { + errors.set(entry.fileID, err); + } } - return result; + return { payloads, errors }; }; diff --git a/test/cli/backup-end-to-end.test.ts b/test/cli/backup-end-to-end.test.ts index 8636a8f..80714ae 100644 --- a/test/cli/backup-end-to-end.test.ts +++ b/test/cli/backup-end-to-end.test.ts @@ -9,7 +9,7 @@ * data this file encrypts with libsodium, as Ente's clients encrypt it. So * quak's own code fetches each original, decrypts it, checks it against the * hash its uploader recorded and unpacks a live photo, and quak's own retries - * repeat a failed request. Two tests run `lib.backup()` instead, as a program + * repeat a failed request. Three tests run `lib.backup()` instead, as a program * using quak as a library does. * * The account, user 42, has three albums: @@ -877,6 +877,133 @@ describe("quak backup", () => { ]); }); + // The server sends beach.jpg's ML data encrypted under a key that is not + // the file's, so quak cannot decrypt it. The file's JSON has the reason + // in place of the ML data, and the file goes into `failures.json` with + // it, so the exit code is 1. The other two files have no ML data, which + // is no failure. + it("records a file whose ML data does not decrypt in its JSON and in failures.json, and exits 1", async () => { + const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const mlData = encryptStreamBlob( + gzipSync(JSON.stringify(ML_DATA)), + otherKey, + ); + const fetch: typeof globalThis.fetch = async (input, init) => { + if (new URL(String(input)).pathname !== "/files/data/fetch") { + return server(input, init); + } + const data = [ + { + fileID: BEACH.id, + encryptedData: toBase64(mlData.ciphertext), + decryptionHeader: toBase64(mlData.header), + }, + ]; + return new Response(JSON.stringify({ data }), { + headers: { "content-type": "application/json" }, + }); + }; + const dir = mkdtempSync(join(root, "backup-")); + + const { code, stderr } = await backup(dir, {}, fetch); + + expect(code).toBe(1); + const reason = "secretstream chunk authentication failed"; + const beachJSON = readJSON(join(dir, DAY, "2024-03-01.100.json")); + expect(beachJSON).not.toHaveProperty("mlData"); + expect(beachJSON.mlDataError).toBe(reason); + const failures = readJSON(join(dir, "failures.json")); + expect(Object.keys(failures.files)).toEqual(["100"]); + expect(failures.files["100"]).toMatchObject({ + fileID: 100, + title: "beach.jpg", + error: `ML data: ${reason}`, + }); + expect(stderr).toContain(`beach.jpg (id 100): ML data: ${reason}\n`); + for (const fileID of [200, 300]) { + const json = readJSON(join(dir, DAY, `2024-03-01.${fileID}.json`)); + expect(json).not.toHaveProperty("mlData"); + expect(json).not.toHaveProperty("mlDataError"); + } + }); + + // A program using quak as a library backs the account up, and another ML + // data fetch, as one the library's refresh starts, fails while the backup + // writes the JSON files. The backup's own fetch got beach.jpg's ML data + // under a key that is not the file's; the fetch that fails leaves that + // reason in place, so beach.jpg's JSON and `failures.json` still give it. + it("keeps the reason in beach.jpg's JSON and in failures.json when an ML data fetch fails while lib.backup() writes the JSON files", async () => { + const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const mlData = encryptStreamBlob( + gzipSync(JSON.stringify(ML_DATA)), + otherKey, + ); + let mlDataFetchFails = false; + const fetch: typeof globalThis.fetch = async (input, init) => { + if (new URL(String(input)).pathname !== "/files/data/fetch") { + return server(input, init); + } + if (mlDataFetchFails) throw new Error("the connection was reset"); + const data = [ + { + fileID: BEACH.id, + encryptedData: toBase64(mlData.ciphertext), + decryptionHeader: toBase64(mlData.header), + }, + ]; + return new Response(JSON.stringify({ data }), { + headers: { "content-type": "application/json" }, + }); + }; + const dir = mkdtempSync(join(root, "backup-")); + const lib = await Library.open({ + client: loadSession(join(sessionDir, "session.json"), { + fetch, + retry: { ...UNATTENDED_RETRY_OPTIONS, sleep: async () => {} }, + })!, + cacheDirectory: `${dir}-cache`, + downloadDirectory: dir, + refreshIntervalSeconds: Infinity, + precacheThumbnails: false, + precacheOriginals: false, + }); + try { + // The backup reads a file's cached ML data just before it writes + // that file's JSON. The first time, a refresh starts an ML data + // fetch whose request fails, and the backup goes on once that + // fetch has failed. + const forFile = lib.mldata.forFile.bind(lib.mldata); + vi.spyOn(lib.mldata, "forFile").mockImplementationOnce( + async (args) => { + mlDataFetchFails = true; + await lib.fresh(); + await vi.waitFor( + () => expect(lib.status().lastMLError).toBeDefined(), + { timeout: 2000, interval: 5 }, + ); + return forFile(args); + }, + ); + + const result = await lib.backup(); + + expect(result.failed).toBe(1); + const reason = "secretstream chunk authentication failed"; + const beachJSON = readJSON(join(dir, DAY, "2024-03-01.100.json")); + expect(beachJSON).not.toHaveProperty("mlData"); + expect(beachJSON.mlDataError).toBe(reason); + const failures = readJSON(join(dir, "failures.json")); + expect(Object.keys(failures.files)).toEqual(["100"]); + expect(failures.files["100"]).toMatchObject({ + fileID: 100, + title: "beach.jpg", + error: `ML data: ${reason}`, + }); + } finally { + await lib.close(); + } + }); + // Something changed beach.jpg on disk after it was backed up. A run with // --verify hashes every stored original, finds that one no longer matches // the hash Ente recorded, says so, and downloads it again; it put back the diff --git a/test/cli/backup.test.ts b/test/cli/backup.test.ts index 7cbb402..135ddfb 100644 --- a/test/cli/backup.test.ts +++ b/test/cli/backup.test.ts @@ -952,13 +952,14 @@ const ML_PAYLOAD = { face: { faces: [] }, clip: { embedding: [0.5, 0.25] } }; class MLClient extends MockClient { mlError?: string; - async fetchMLData(args: { - fileIDs: number[]; - }): Promise<Map<number, MLData>> { + async fetchMLData(args: { fileIDs: number[] }): Promise<{ + payloads: Map<number, MLData>; + errors: Map<number, unknown>; + }> { if (this.mlError) throw new Error(this.mlError); - const result = new Map<number, MLData>(); - if (args.fileIDs.includes(100)) result.set(100, ML_PAYLOAD); - return result; + const payloads = new Map<number, MLData>(); + if (args.fileIDs.includes(100)) payloads.set(100, ML_PAYLOAD); + return { payloads, errors: new Map() }; } } @@ -1573,6 +1574,7 @@ describe("backup album folders", () => { contentConcurrency: 5, fetchMLData: async () => {}, mlData: async () => undefined, + mlDataError: () => undefined, whoami: () => ({ email: "backup@example.com", userID: USER_ID }), }); diff --git a/test/cli/metadata-backup.test.ts b/test/cli/metadata-backup.test.ts index 5bff19c..0902e44 100644 --- a/test/cli/metadata-backup.test.ts +++ b/test/cli/metadata-backup.test.ts @@ -489,15 +489,15 @@ describe("quak backup-metadata", () => { }); }); -describe("quak backup-metadata when an ML data request fails", () => { - // Run the CLI command against the mock and return its exit code, stderr - // and output directory. - const runCommand = async (failMLDataFor?: number) => { +describe("quak backup-metadata when a file's ML data cannot be read", () => { + // Run the CLI command against `fetch`, the mock unless given, and return + // its exit code, stderr and output directory. + const runCommand = async (fetch = buildMetaFetch(mock)) => { const client = await Client.login({ email: TEST_EMAIL, password: TEST_PASSWORD, apiOptions: { - fetch: buildMetaFetch(mock, failMLDataFor), + fetch, retry: { sleep: async () => {} }, }, }); @@ -518,7 +518,9 @@ describe("quak backup-metadata when an ML data request fails", () => { }; it("writes every file, marks the failed batch's files, and exits 1", async () => { - const { code, stderr, outDir } = await runCommand(200); + const { code, stderr, outDir } = await runCommand( + buildMetaFetch(mock, 200), + ); expect(code).toBe(1); expect(stderr).toContain("ML data request for 1 file(s) failed"); @@ -543,6 +545,49 @@ describe("quak backup-metadata when an ML data request fails", () => { expect(failed.mlDataError).toContain("500"); }); + // The server sends ML data for diagram.png encrypted under a key that is + // not the file's, so quak cannot decrypt it. + it("writes the reason for ML data that does not decrypt, and exits 1", async () => { + const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const { header, ciphertext } = encryptBlob( + new Uint8Array(gzipSync(Buffer.from("{}"))), + otherKey, + ); + const { code, stderr, outDir } = await runCommand( + buildMetaFetch({ + ...mock, + encryptedMLData: { + ...mock.encryptedMLData, + 200: { + encryptedData: toBase64(ciphertext), + decryptionHeader: toBase64(header), + }, + }, + }), + ); + + expect(code).toBe(1); + const reason = "secretstream chunk authentication failed"; + expect(stderr).toContain( + `ML data of file 200 could not be read: ${reason}\n`, + ); + const failed = JSON.parse( + readFileSync( + join(outDir, "collections", "20-__Work", "200.json"), + "utf-8", + ), + ); + expect(failed.mlData).toBeUndefined(); + expect(failed.mlDataError).toBe(reason); + const ok = JSON.parse( + readFileSync( + join(outDir, "collections", "10-Vacation", "100.json"), + "utf-8", + ), + ); + expect(ok.mlData.clip.embedding).toEqual([0.5, 0.6, 0.7]); + }); + it("exits 0 when every ML data request succeeds", async () => { const { code } = await runCommand(); expect(code).toBe(0); diff --git a/test/library/mldata.test.ts b/test/library/mldata.test.ts index 3de1cdc..78ad662 100644 --- a/test/library/mldata.test.ts +++ b/test/library/mldata.test.ts @@ -12,9 +12,11 @@ * * 2. `Library` wiring: after each refresh the library fetches ML data through * the metadata pool for every known file not yet cached, is incremental on - * later refreshes, and refetches a file whose `updationTime` advanced. - * Opening a cache directory written by another account starts empty, - * its ML data included (issue #104). + * later refreshes, and refetches a file whose `updationTime` advanced. A + * file whose ML data did not decode is reported with the reason, which + * stays listed until the next pass ends, and asked for again. Opening a + * cache directory written by another account starts empty, its ML data + * included (issue #104). * * Embedding values are chosen to be exactly representable as float32 so the * round-trip through `clip.f32` compares equal. @@ -26,7 +28,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { MLDataStore } from "../../src/library/mldata.js"; -import { Library } from "../../src/library/index.js"; +import { Library, type RefreshEvent } from "../../src/library/index.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js"; import type { MLData } from "../../src/mldata-fetch.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; @@ -256,12 +258,14 @@ const file = ( // A mock client that serves scripted collection/file pages and per-file ML // payloads, recording every ML fetch request so incremental behaviour is -// provable. +// provable. A file in `mlErrorByFile` has ML data that did not decode, for +// the reason given there. class MLMockClient { userID = USER_ID; collectionsQueue: CollectionsPage[] = []; filesByCollection = new Map<number, FilesPage[]>(); mlByFile = new Map<number, MLData>(); + mlErrorByFile = new Map<number, Error>(); mlFetchCalls: number[][] = []; whoami(): { email: string; userID: number } { @@ -298,14 +302,20 @@ class MLMockClient { async fetchMLData(args: { fileIDs: number[]; fileKeys: Map<number, Uint8Array>; - }): Promise<Map<number, MLData>> { + }): Promise<{ + payloads: Map<number, MLData>; + errors: Map<number, unknown>; + }> { this.mlFetchCalls.push([...args.fileIDs]); - const result = new Map<number, MLData>(); + const payloads = new Map<number, MLData>(); + const errors = new Map<number, unknown>(); for (const id of args.fileIDs) { const p = this.mlByFile.get(id); - if (p) result.set(id, p); + if (p) payloads.set(id, p); + const error = this.mlErrorByFile.get(id); + if (error) errors.set(id, error); } - return result; + return { payloads, errors }; } filesFor(collectionID: number, ...pages: FilesPage[]): void { @@ -449,6 +459,137 @@ describe("Library ML-data fetch on refresh", () => { } }); + it("reports a file whose ML data did not decode through status() and onProgress, and asks for it again", async () => { + const client = new MLMockClient(); + client.collectionsQueue.push({ + collections: [collection(1, 100)], + deleted: [], + cursor: 100, + }); + client.filesFor(1, { + files: [file(1001, 1, 90), file(1002, 1, 95)], + deleted: [], + cursor: 95, + }); + client.mlByFile.set(1001, payload([0.5, 0.25, 0.75])); + client.mlErrorByFile.set( + 1002, + new Error("secretstream chunk authentication failed"), + ); + const events: RefreshEvent[] = []; + + const lib = await Library.open({ + client, + cacheDirectory, + refreshIntervalSeconds: Infinity, + onProgress: (event) => events.push(event), + }); + try { + await vi.waitFor( + () => expect(lib.status().lastMLFetchAt).toBeGreaterThan(0), + { timeout: 2000, interval: 5 }, + ); + const reasons = [ + { + fileID: 1002, + error: "secretstream chunk authentication failed", + }, + ]; + expect(lib.status().mlDataErrors).toEqual(reasons); + expect(lib.status().mlStored).toBe(1); + expect(events.filter((e) => e.operation === "fetchMLData")).toEqual( + [ + { operation: "fetchMLData", status: "started" }, + { + operation: "fetchMLData", + status: "done", + fetched: 1, + mlDataErrors: reasons, + }, + ], + ); + + // Nothing was cached for 1002, so the pass a refresh starts asks + // for it again, and once its ML data decodes the reason is gone. + client.mlErrorByFile.clear(); + client.mlByFile.set(1002, payload([1, -2, 0.5])); + await lib.fresh(); + await vi.waitFor( + () => { + expect(lib.status().mlStored).toBe(2); + expect(lib.status().mlDataErrors).toEqual([]); + }, + { timeout: 2000, interval: 5 }, + ); + expect(client.mlFetchCalls.at(-1)).toEqual([1002]); + } finally { + await lib.close(); + } + }); + + it("still lists the last pass's reasons while the next pass runs", async () => { + const client = new MLMockClient(); + client.collectionsQueue.push({ + collections: [collection(1, 100)], + deleted: [], + cursor: 100, + }); + client.filesFor(1, { + files: [file(1002, 1, 95)], + deleted: [], + cursor: 95, + }); + client.mlErrorByFile.set( + 1002, + new Error("secretstream chunk authentication failed"), + ); + const reasons = [ + { fileID: 1002, error: "secretstream chunk authentication failed" }, + ]; + let release!: () => void; + const held = new Promise<void>((r) => (release = r)); + + const lib = await Library.open({ + client, + cacheDirectory, + refreshIntervalSeconds: Infinity, + }); + try { + await vi.waitFor( + () => expect(lib.status().lastMLFetchAt).toBeGreaterThan(0), + { timeout: 2000, interval: 5 }, + ); + expect(lib.status().mlDataErrors).toEqual(reasons); + + // Hold the next ML data fetch open until the test releases it. + let fetchStarted!: () => void; + const started = new Promise<void>((r) => (fetchStarted = r)); + const realFetch = client.fetchMLData.bind(client); + client.fetchMLData = async (args) => { + fetchStarted(); + await held; + return realFetch(args); + }; + client.mlErrorByFile.clear(); + client.mlByFile.set(1002, payload([1, -2, 0.5])); + + // The pass a refresh starts asks for 1002 again. While it is + // held, the reason from the pass before is still listed; once it + // ends, its own empty list takes the place of that one. + await lib.fresh(); + await started; + expect(lib.status().mlDataErrors).toEqual(reasons); + release(); + await vi.waitFor( + () => expect(lib.status().mlDataErrors).toEqual([]), + { timeout: 2000, interval: 5 }, + ); + } finally { + release(); + await lib.close(); + } + }); + it("close() resolves only after a running ML data fetch has stored its payloads", async () => { const client = new MLMockClient(); client.collectionsQueue.push({ diff --git a/test/mldata-fetch/mldata-fetch.test.ts b/test/mldata-fetch/mldata-fetch.test.ts new file mode 100644 index 0000000..b2e4b0e --- /dev/null +++ b/test/mldata-fetch/mldata-fetch.test.ts @@ -0,0 +1,54 @@ +// `fetchMLDataBatch`, against a server that sends one file's ML data. +// +// ML data that decrypts and gunzips but is not a JSON object is reported with +// the reason, as ML data that does not parse is, and is never returned as a +// payload: the library would fail to store it. + +import { gzipSync } from "node:zlib"; +import sodium from "libsodium-wrappers-sumo"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { ApiClient } from "../../src/api/client.js"; +import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js"; +import { fetchMLDataBatch } from "../../src/mldata-fetch.js"; + +beforeAll(async () => { + await init(); + await sodium.ready; +}); + +// Fetch the ML data of file 1 from a server that sends `json`, gzipped and +// encrypted with the file's key. +const fetchServing = async (json: string) => { + const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const { header, ciphertext } = encryptBlob( + new Uint8Array(gzipSync(json)), + key, + ); + const data = [ + { + fileID: 1, + encryptedData: toBase64(ciphertext), + decryptionHeader: toBase64(header), + }, + ]; + const api = new ApiClient({ + fetch: async () => + new Response(JSON.stringify({ data }), { + headers: { "content-type": "application/json" }, + }), + }); + return fetchMLDataBatch(api, [1], new Map([[1, key]])); +}; + +describe("fetchMLDataBatch", () => { + it.each(["null", "5", "[]"])( + "gives a reason for ML data that is %s", + async (json) => { + const { payloads, errors } = await fetchServing(json); + + expect(payloads.size).toBe(0); + expect(errors.get(1)).toEqual(new Error("not a JSON object")); + }, + ); +}); From 982b878d6929c4cefdd80e5fea212a3c532bfb56 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 18 Oct 2026 19:01:34 +0200 Subject: [PATCH 36/39] quak collections reads the collection list alone (closes #210) On an empty cache the read commands opened their library with a refresh that fetched and decrypted the files of every collection in turn, printing nothing and storing nothing until it had them all, so `quak collections` seemed to hang on a large account. The new `lib.freshCollections()` is the fresh read of the collection list alone: one request, and nothing stored, so a later refresh still fetches every collection's files. The read commands open their library with the new `refreshOnOpen: false` and make only the refresh they ask for. A refresh sends a "progress" event after each collection whose files it fetched; the commands that wait on one print it on stderr, except with `--json`. Model: opus-5-5 --- README.md | 34 +++++- TODO.md | 11 ++ src/cli-commands.ts | 35 ++++-- src/cli-read.ts | 29 ++--- src/library/index.ts | 66 +++++++++-- test/cli/commands.test.ts | 215 +++++++++++++++++++++++++++++++++-- test/cli/read.test.ts | 31 +++-- test/library/fresh.test.ts | 70 ++++++++++++ test/library/library.test.ts | 104 +++++++++++++++++ 9 files changed, 537 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 4ff51c2..5a53ae7 100644 --- a/README.md +++ b/README.md @@ -661,7 +661,15 @@ library. The read commands — `collections`, `files`, `get`, `get-thumb`, `backup-metadata`, `helper list-missing-thumbnails` and `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 -held. If that round-trip fails, the command prints the error on one line and +held. `collections` asks the server for the collection list alone, in one +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 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"). @@ -961,7 +969,11 @@ loop, and resolves to a `Library`. On an empty cache it awaits the first refresh, so it opens onto the account's data whenever the server is reachable; 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 -background, so an unreachable server does not block opening. +background, so an unreachable server does not block opening. With +`refreshOnOpen: false` it starts no refresh and returns at once onto what the +cache holds, empty or not; a refresh then starts only when `fresh()` or +`lib.backup()` asks, or the background timer fires. The CLI's read commands open +it so (see "CLI surface"). `LibraryOptions`: @@ -971,6 +983,7 @@ background, so an unreachable server does not block opening. | `cacheDirectory` | `quak/<userID>` under the per-user cache directory | where `metadata.json` and the content cache live | | `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 | +| `refreshOnOpen` | `true` | start a refresh as the library opens | | `precacheThumbnails` | `true` | prefetch every thumbnail, newest first | | `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals | | `precacheOriginalsDays` | `7` | length in days of that latest window | @@ -984,6 +997,14 @@ background, so an unreachable server does not block opening. The default `downloadDirectory` is resolved against the working directory once, 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 `new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })` and pass it. The three pools default to 10 / 5 / 25 (see Request pools below). @@ -1023,6 +1044,15 @@ rejects the caller (default reads stay silent and keep serving the last good copy). The CLI's read commands use fresh reads (issue 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 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 diff --git a/TODO.md b/TODO.md index 840b2bb..02a97ff 100644 --- a/TODO.md +++ b/TODO.md @@ -26,6 +26,17 @@ declares one. # Completed Steps +- 2026-10-18: `quak collections` no longer waits for the files of every + collection (issue 210). On an empty cache it opened its library with a refresh + that fetched and decrypted the files of each collection in turn, printing + nothing and storing nothing until all were done, so on a large account it + seemed to hang. It now reads the collection list alone through the new + `lib.freshCollections()`, one request that stores nothing. The read commands + open their library with the new `refreshOnOpen: false`, so each makes only the + refresh it asks for. A refresh sends a `"progress"` event after each + collection whose files it fetched, and the commands that wait on one print it + on stderr, except with `--json`. + - 2026-10-16: ML data the server sends that does not decrypt, gunzip or parse is no longer taken for no ML data (issue 198). `fetchMLDataBatch` returns why for each such file beside the payloads; ML data that parses to something other diff --git a/src/cli-commands.ts b/src/cli-commands.ts index a6d8b8a..6e71734 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -125,15 +125,29 @@ const readLibraryClient = (client: Client): LibraryClient => ({ // 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 -// downloading the whole account — and the refresh interval is long so no second -// refresh fires mid-command. -const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> => +// downloading the whole account — and no refresh starts as it opens or on a +// timer: each command asks for the one it needs, and `collections` needs none +// (issue #210). While a refresh runs, each collection whose files it has +// fetched is 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({ client: readLibraryClient(client), cacheDirectory: ctx.cacheDir, - refreshIntervalSeconds: 3600, + refreshOnOpen: false, + refreshIntervalSeconds: Infinity, precacheThumbnails: 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> => { @@ -221,9 +235,10 @@ export const collectionsCommand = async ( if (!client) return 3; const lib = await openReadLibrary(ctx, client); try { - // Force a server round-trip and list in enumeration order (issue #36 - // amendment, issue #52): the pre-library CLI printed current state in - // this order, not the albums projection's newest-first order. + // Ask the server for the collection list alone and list it in its + // order (issue #36 amendment, issues #52 and #210): the pre-library + // CLI printed current state in this order, not the albums + // projection's newest-first order. const collections = await freshCollections(lib); if (opts.json) { @@ -264,7 +279,7 @@ export const filesCommand = async ( const collectionID = parseID(ctx, "collection", opts.collection); if (collectionID === undefined) return 1; - const lib = await openReadLibrary(ctx, client); + const lib = await openReadLibrary(ctx, client, opts); try { // Force a server round-trip and list in enumeration order (issue #36 // amendment, issue #52). Each file prints from its own decrypted @@ -490,7 +505,7 @@ export const listMissingThumbnailsCommand = async ( await init(); const client = requireSession(ctx); if (!client) return 3; - const lib = await openReadLibrary(ctx, client); + const lib = await openReadLibrary(ctx, client, opts); try { // Refresh first so files added since the cache was written are // checked; a failed refresh throws. @@ -535,7 +550,7 @@ export const fixMissingThumbnailsCommand = async ( if (fileID === undefined) return 1; fileIDs.push(fileID); } - const lib = await openReadLibrary(ctx, client); + const lib = await openReadLibrary(ctx, client, opts); try { // Refresh first so files added since the cache was written are found; // a failed refresh throws. diff --git a/src/cli-read.ts b/src/cli-read.ts index 6f3c4ad..e009aa3 100644 --- a/src/cli-read.ts +++ b/src/cli-read.ts @@ -2,12 +2,16 @@ // // `collections`, `files --collection`, `get`, and `get-thumb` must answer for // the account's state at the moment the command runs, not for whatever the -// local cache last happened to hold (owner amendment, issue #36). Each helper -// therefore forces a server round-trip through `Library.fresh()` and only then -// reads — so a collection, file, or metadata change made elsewhere is visible. +// local cache last happened to hold (owner amendment, issue #36). `files`, +// `get` and `get-thumb` therefore force a server round-trip through +// `Library.fresh()` and only then read — so a collection, file, or metadata +// change made elsewhere is visible. `collections` asks the library for the +// collection list alone through `Library.freshCollections()` (issue #210): a +// refresh would also fetch the files of every collection, which on a large +// account takes long and which `collections` does not print. // -// `collections` and `files` also list in the library's own enumeration order — -// `listCollections()`/`listFiles()`, the order the pre-library CLI printed — +// `collections` lists in the server's order and `files` in the library's own +// enumeration order (`listFiles()`), the orders the pre-library CLI printed, // 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 // metadata via `cli-output.ts`. @@ -17,23 +21,22 @@ import type { Photo, PhotosAPI } from "./library/index.js"; // The slice of `Library` these helpers read. `Library` satisfies it // structurally; a test can drive them with a stand-in that records the -// `fresh()` call and serves records in a known enumeration order. +// `fresh()` and `freshCollections()` calls and serves records in a known +// enumeration order. export interface FreshReadLibrary { fresh(): Promise<unknown>; - listCollections(): Collection[]; + freshCollections(): Promise<Collection[]>; getCollection(id: number): Collection | undefined; listFiles(collectionID: number): EnteFile[]; getFileByID(fileID: number): EnteFile | undefined; photos: Pick<PhotosAPI, "byID">; } -// Every live collection, current as of a forced refresh, in enumeration order. -export const freshCollections = async ( +// Every live collection, current as of one collections request, in the +// server's order. +export const freshCollections = ( lib: FreshReadLibrary, -): Promise<Collection[]> => { - await lib.fresh(); - return lib.listCollections(); -}; +): Promise<Collection[]> => lib.freshCollections(); // 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 diff --git a/src/library/index.ts b/src/library/index.ts index be62a1b..a3bed63 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -18,6 +18,16 @@ // the caller (the default reads stay silent and serve the last good copy). The // 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 // 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 @@ -161,18 +171,21 @@ export interface LibraryClient { // exactly one of "done" or "failed"; "failed" carries the error message and // 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. -// The precache fills ("precacheThumbnails"/"precacheOriginals", #48) fire -// "started"/"done" around each sweep that has work, "done" reporting the count -// newly cached. +// In between, a refresh fires "progress" after each collection whose files it +// fetched: `fetched` is how many collections it has fetched the files of so +// far, `total` how many it will. The precache fills +// ("precacheThumbnails"/"precacheOriginals", #48) fire "started"/"done" around +// each sweep that has work, "done" reporting the count newly cached. export interface RefreshEvent { operation: | "refresh" | "fetchMLData" | "precacheThumbnails" | "precacheOriginals"; - status: "started" | "done" | "failed"; + status: "started" | "progress" | "done" | "failed"; error?: string; fetched?: number; + total?: number; mlDataErrors?: { fileID: number; error: string }[]; } @@ -192,6 +205,10 @@ export interface LibraryOptions { // `Infinity` starts none: the library then refreshes only as it opens and // when `fresh()` or `backup()` asks. 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; // 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 @@ -382,7 +399,8 @@ export class Library { // then opens empty with the failure recorded in `status()`. With an // existing cache the first refresh runs in the background and `open()` // returns as soon as the cached data is ready — an unreachable server does - // not block opening. + // not block opening. With `refreshOnOpen: false` there is no first + // refresh: `open()` returns onto the cache as it is. static async open(opts: LibraryOptions): Promise<Library> { const { userID } = opts.client.whoami(); const cacheDirectory = @@ -485,7 +503,10 @@ export class Library { // refresh. precache?.start(); - if (store.loadedFromDisk) { + if (opts.refreshOnOpen === false) { + // The caller asks for each refresh it needs. + lib.scheduleNext(); + } else if (store.loadedFromDisk) { // An existing copy already answers reads; refresh in the background // and start the interval once that first cycle settles. void lib.runRefresh().then(() => lib.scheduleNext()); @@ -589,6 +610,21 @@ export class Library { }; } + // The fresh read of the collection list alone (issue #210): every live + // collection that decrypts, in the server's order, from one collections + // request. Unlike a refresh, it fetches no collection's files, which on a + // large account take long. Nothing is stored: a refresh fetches a + // collection's files only when the collection is new or has changed since + // the stored copy, so a collection stored here without its files would + // never have them fetched. + async freshCollections(): Promise<Collection[]> { + if (this.closed) throw new Error("the library is closed"); + const { collections } = await this.client.collectionsSince({ + sinceTime: 0, + }); + return collections; + } + // Back up every in-scope file to `opts.downloadDirectory`, or else the // library's, each original at its save path, with a durable failure // ledger (issue #51). Takes the lock in that directory first, unless the @@ -742,18 +778,28 @@ export class Library { // Stage per-collection file diffs. A collection's files are // re-enumerated only when its updationTime has advanced past the cached // copy; an unchanged album's file list cannot have changed. New - // collections enumerate from the beginning of time. + // collections enumerate from the beginning of time. Each collection + // whose files are fetched is reported, with how many there are to + // fetch. + const changedCollections = page.collections.filter((collection) => { + const known = this.store.getCollection(collection.id); + return !known || collection.updationTime > known.updationTime; + }); const filePages: { collectionID: number; page: FilesPage }[] = []; - for (const collection of page.collections) { + for (const collection of changedCollections) { const known = this.store.getCollection(collection.id); - if (known && collection.updationTime <= known.updationTime) - continue; const filePage = await this.client.filesSince({ collectionID: collection.id, collectionKey: collection.key, sinceTime: known ? known.updationTime : 0, }); 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 diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index 241e259..6da51e1 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -53,11 +53,21 @@ import { } from "../../src/cli-commands.js"; import { run } from "../../src/cli-run.js"; import { loadSession } from "../../src/cli-session.js"; -import type { Client, ClientSnapshot, LoginOptions } from "../../src/client.js"; +import { + Client, + type ClientSnapshot, + type LoginOptions, +} from "../../src/client.js"; import { savePath, type ContentSource } from "../../src/library/content.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; import { encryptBlob, init, toBase64 } from "../../src/crypto/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 { asLivePhoto, @@ -171,6 +181,12 @@ const fakeClient = ( 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"; + // Collects everything written to it. class Output { text = ""; @@ -510,7 +526,9 @@ describe("files", () => { it("exits 1 for an unknown collection", async () => { expect(await filesCommand(context(), { collection: "9" })).toBe(1); - expect(stderr.text).toBe("Collection 9 not found\n"); + expect(stderr.text).toBe( + FETCHED_BOTH_ALBUMS + "Collection 9 not found\n", + ); }); }); @@ -519,27 +537,27 @@ describe("get and get-thumb", () => { const out = join(root, "diagram.png"); expect(await getCommand(context(), "200", { out })).toBe(0); expect(readFileSync(out)).toEqual(Buffer.alloc(7, 200)); - expect(stderr.text).toBe(`7 bytes -> ${out}\n`); + expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + `7 bytes -> ${out}\n`); }); it("get-thumb finds a file in any album without --collection", async () => { const out = join(root, "thumb.jpg"); expect(await getThumbCommand(context(), "200", { out })).toBe(0); expect(readFileSync(out)).toEqual(Buffer.alloc(3, 200)); - expect(stderr.text).toBe(`3 bytes -> ${out}\n`); + expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + `3 bytes -> ${out}\n`); }); it("get exits 1 when no album has the file", async () => { const out = join(root, "x"); expect(await getCommand(context(), "999", { out })).toBe(1); - expect(stderr.text).toBe("File 999 not found\n"); + expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + "File 999 not found\n"); expect(existsSync(out)).toBe(false); }); it("get-thumb exits 1 when no album has the file", async () => { const out = join(root, "x"); expect(await getThumbCommand(context(), "999", { out })).toBe(1); - expect(stderr.text).toBe("File 999 not found\n"); + expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + "File 999 not found\n"); expect(existsSync(out)).toBe(false); }); }); @@ -549,6 +567,9 @@ describe("get and get-thumb", () => { // one live photo, 300, downloaded through the real download layer // (test/live-photo.ts). describe("a live photo", () => { + // What `get` prints on stderr while its refresh fetches Vacation's files. + const FETCHED_ONE_ALBUM = "Fetched the files of 1 of 1 collections\n"; + const livePhotoClient = async (image = IMAGE): Promise<Client> => { const { file: live, body } = await asLivePhoto( file(300, 1, "IMG_0300.HEIC"), @@ -593,7 +614,8 @@ describe("a live photo", () => { Buffer.from(VIDEO), ); expect(stderr.text).toBe( - `${IMAGE.length} bytes -> IMG_0300.heic\n` + + FETCHED_ONE_ALBUM + + `${IMAGE.length} bytes -> IMG_0300.heic\n` + `${VIDEO.length} bytes -> IMG_0300.mov\n`, ); }); @@ -609,7 +631,8 @@ describe("a live photo", () => { expect(readFileSync(out)).toEqual(Buffer.from(IMAGE)); expect(readFileSync(video)).toEqual(Buffer.from(VIDEO)); expect(stderr.text).toBe( - `${IMAGE.length} bytes -> ${out}\n${VIDEO.length} bytes -> ${video}\n`, + FETCHED_ONE_ALBUM + + `${IMAGE.length} bytes -> ${out}\n${VIDEO.length} bytes -> ${video}\n`, ); }); @@ -623,7 +646,8 @@ describe("a live photo", () => { expect(existsSync(out)).toBe(false); expect(existsSync(join(root, "photo.mov"))).toBe(false); expect(stderr.text).toBe( - `File 300 is a live photo, and its video would also be written to ${out}\n`, + FETCHED_ONE_ALBUM + + `File 300 is a live photo, and its video would also be written to ${out}\n`, ); }); @@ -1103,11 +1127,12 @@ describe("backup-metadata --exif", () => { }); }); -// Each test first runs `collections` so the cache holds the account as it was, -// then changes the server under it. +// Each test first runs `files` so the cache holds the account as it was, then +// changes the server under it. (`collections` would not do: it reads the +// collection list alone and stores nothing.) describe("backup-metadata and the thumbnail helpers refresh first", () => { beforeEach(async () => { - expect(await collectionsCommand(context(), {})).toBe(0); + expect(await filesCommand(context(), { collection: "1" })).toBe(0); stdout.text = ""; stderr.text = ""; }); @@ -1292,3 +1317,169 @@ 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, { collection: "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(), { collection: "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(), { + collection: "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(""); + }); +}); diff --git a/test/cli/read.test.ts b/test/cli/read.test.ts index a218b5c..08821a1 100644 --- a/test/cli/read.test.ts +++ b/test/cli/read.test.ts @@ -3,10 +3,13 @@ * issue #36, issue #52). * * The `collections`, `files`, `get`, and `get-thumb` commands must answer for - * current server state, not the local cache, so each helper forces a - * `Library.fresh()` round-trip before it reads. The stand-in library below - * serves nothing until `fresh()` has been awaited, so a helper that read - * without refreshing would come back empty and fail here. + * current server state, not the local cache. `files`, `get` and `get-thumb` + * force a `Library.fresh()` round-trip before they read; the stand-in library + * below serves nothing until `fresh()` has been awaited, so a helper that read + * without refreshing would come back empty and fail here. `collections` asks + * the library for the collection list alone (`freshCollections()`, issue + * #210), and must not wait for a refresh, which would fetch the files of every + * collection. * * `collections` and `files` also list in the library's enumeration order * (`listCollections`/`listFiles`) — the order the pre-library CLI printed — not @@ -60,10 +63,13 @@ const file = ( }); // A library that reveals its records only after `fresh()` has been awaited, and -// serves them in the enumeration order it was given. `photos.byID` returns a -// stand-in `Photo` carrying just the fileID the helper passes through. +// serves them in the enumeration order it was given. `freshCollections()` +// answers the collection list in that order without a refresh. `photos.byID` +// returns a stand-in `Photo` carrying just the fileID the helper passes +// through. class FakeLibrary implements FreshReadLibrary { freshCalls = 0; + freshCollectionsCalls = 0; private refreshed = false; constructor( @@ -77,12 +83,14 @@ class FakeLibrary implements FreshReadLibrary { return {}; } - listCollections(): Collection[] { - return this.refreshed ? this.collections : []; + async freshCollections(): Promise<Collection[]> { + this.freshCollectionsCalls++; + return this.collections; } getCollection(id: number): Collection | undefined { - return this.listCollections().find((c) => c.id === id); + if (!this.refreshed) return undefined; + return this.collections.find((c) => c.id === id); } listFiles(collectionID: number): EnteFile[] { @@ -106,7 +114,7 @@ class FakeLibrary implements FreshReadLibrary { } describe("CLI read helpers (issue #36 amendment, issue #52)", () => { - it("freshCollections refreshes first, then lists in enumeration order", async () => { + it("freshCollections reads the collection list alone, in enumeration order", async () => { // Enumeration order 2, 1, 3; a newest-first sort would be 3, 2, 1. const lib = new FakeLibrary( [collection(2, 200), collection(1, 300), collection(3, 100)], @@ -114,7 +122,8 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => { ); const rows = await freshCollections(lib); - expect(lib.freshCalls).toBe(1); + expect(lib.freshCollectionsCalls).toBe(1); + expect(lib.freshCalls).toBe(0); expect(rows.map((c) => c.id)).toEqual([2, 1, 3]); // The projection's newest-first order is a different sequence, so this // is not accidentally that order. diff --git a/test/library/fresh.test.ts b/test/library/fresh.test.ts index 10b8ee4..859a8da 100644 --- a/test/library/fresh.test.ts +++ b/test/library/fresh.test.ts @@ -14,6 +14,9 @@ * three. * 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. + * 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: * no crypto, no network, scripted pages, and a record of each call. A long @@ -270,3 +273,70 @@ describe("Library.fresh", () => { } }); }); + +describe("Library.freshCollections", () => { + let dir: string; + let cacheDirectory: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "quak-fresh-collections-")); + cacheDirectory = join(dir, "cache"); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + // A refresh fetches a collection's files only when the collection is new + // or its updationTime has advanced past the stored copy's. Had the + // collection list been stored, the next refresh would take every + // collection for one it already holds the files of, and fetch none. + it("asks for the collection list alone and stores nothing", async () => { + const client = new MockClient(); + const page = { + collections: [collection(2, 100), collection(1, 110)], + deleted: [], + cursor: 110, + }; + client.collectionsQueue.push(page, page); + client.filesFor(1, { + files: [file(1001, 1, 90)], + deleted: [], + cursor: 90, + }); + client.filesFor(2, { + files: [file(2001, 2, 95)], + deleted: [], + cursor: 95, + }); + + const lib = await Library.open({ + client, + cacheDirectory, + refreshOnOpen: false, + refreshIntervalSeconds: SLOW_INTERVAL, + }); + try { + // One request, the whole list in the server's order, and no + // collection's files. + const collections = await lib.freshCollections(); + expect(collections.map((c) => c.id)).toEqual([2, 1]); + expect(collections.map((c) => c.name)).toEqual([ + "album-2", + "album-1", + ]); + expect(client.collectionsSinceTimes).toEqual([0]); + expect(client.filesCalls).toEqual([]); + expect(lib.listCollections()).toEqual([]); + + // The next refresh still asks from the beginning and fetches the + // files of both collections. + const reads = await lib.fresh(); + expect(client.collectionsSinceTimes).toEqual([0, 0]); + expect(reads.photos.byID({ fileID: 1001 })?.fileID).toBe(1001); + expect(reads.photos.byID({ fileID: 2001 })?.fileID).toBe(2001); + } finally { + await lib.close(); + } + }); +}); diff --git a/test/library/library.test.ts b/test/library/library.test.ts index 2669871..def8c33 100644 --- a/test/library/library.test.ts +++ b/test/library/library.test.ts @@ -26,6 +26,9 @@ * opening. * 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. + * 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 * records the `sinceTime` each call carried so cursor threading is provable. @@ -581,6 +584,107 @@ describe("Library.open and background refresh", () => { } }); + // A caller that asks for each refresh it needs, as the CLI's read commands + // do, opens without one: on an empty cache the refresh `open()` would + // await fetches the files of every collection, which `quak collections` + // never needs (issue #210). + it("starts no refresh as it opens when refreshOnOpen is false", async () => { + const client = new MockClient(); + client.collectionsQueue.push({ + collections: [collection(1, 100)], + deleted: [], + cursor: 100, + }); + + const lib = await Library.open({ + client, + cacheDirectory, + refreshOnOpen: false, + refreshIntervalSeconds: Infinity, + }); + try { + expect(client.collectionsSinceTimes).toEqual([]); + expect(lib.listCollections()).toEqual([]); + expect(lib.status().lastRefreshAt).toBeUndefined(); + + // The refresh comes when the caller asks for it. + await lib.fresh(); + expect(client.collectionsSinceTimes).toEqual([0]); + expect(lib.listCollections().map((c) => c.id)).toEqual([1]); + } finally { + await lib.close(); + } + }); + + // A refresh fetches the files of each collection that changed, one + // collection after another, and stores nothing until it has them all. On + // a large account that takes a while, so it reports each collection whose + // files it has fetched, and how many it will fetch in all (issue #210). + it("reports each collection whose files a refresh fetched", async () => { + const events: RefreshEvent[] = []; + const client = new MockClient(); + client.collectionsQueue.push({ + collections: [ + collection(1, 100), + collection(2, 100), + collection(3, 100), + ], + deleted: [], + cursor: 100, + }); + + const lib = await Library.open({ + client, + cacheDirectory, + refreshIntervalSeconds: Infinity, + onProgress: (e) => events.push(e), + }); + try { + expect(events).toEqual([ + { operation: "refresh", status: "started" }, + { + operation: "refresh", + status: "progress", + fetched: 1, + total: 3, + }, + { + operation: "refresh", + status: "progress", + fetched: 2, + total: 3, + }, + { + operation: "refresh", + status: "progress", + fetched: 3, + total: 3, + }, + { operation: "refresh", status: "done" }, + ]); + + // Only collection 2 changed since, so the next refresh fetches + // its files alone and counts one. + events.length = 0; + client.collectionsQueue.push({ + collections: [collection(2, 200)], + deleted: [], + cursor: 200, + }); + await lib.fresh(); + expect(events.filter((e) => e.status === "progress")).toEqual([ + { + operation: "refresh", + status: "progress", + fetched: 1, + total: 1, + }, + ]); + } finally { + await lib.close(); + } + }); + it("keeps a save failure visible until a save actually succeeds", async () => { const saveSpy = vi .spyOn(MetadataStore.prototype, "save") From 66e3ac77e8ace59bbfb9ecf8317906fbec157b98 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 18 Oct 2026 20:01:34 +0200 Subject: [PATCH 37/39] Names quak builds from server data fit in 255 bytes (closes #205) The default names of `quak get`, `quak get-thumb`, `downloadFile` and `downloadThumbnail`, and the album folders of `backup-metadata`, could be over 255 bytes, which a file system refuses. `defaultFileName` and `shortenKeepingExtension` in `src/filename.ts` now cut such a name short, keeping its extension when it fits; a live photo's image and video get their own extension before the cut. A cut name never starts with a dot and is never a Windows device name. `--out` and `outPath` are kept as given. A `backup-metadata` folder keeps its `<id>-`. `safeExtension` gives `.bin` for an extension of more than 16 letters and digits, so a save path's name always fits. Model: opus-5-5 --- README.md | 58 ++++++++----- TODO.md | 23 +++++- src/backup.ts | 16 ++-- src/cli-commands.ts | 12 ++- src/cli-output.ts | 16 ++-- src/download/index.ts | 39 ++++++--- src/filename.ts | 60 +++++++++++++- src/metadata-backup.ts | 16 +++- test/cli/backup.test.ts | 21 +++-- test/cli/commands.test.ts | 145 +++++++++++++++++++++++++++++++-- test/download/download.test.ts | 89 ++++++++++++++++++++ test/filename/filename.test.ts | 108 ++++++++++++++++++++++++ 12 files changed, 526 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 5a53ae7..54c4105 100644 --- a/README.md +++ b/README.md @@ -683,27 +683,41 @@ before any request with one line, `Invalid file ID: <arg>` or `Invalid collection ID: <arg>`, and exit status 1. One bad `--file` ID refuses the whole command, so nothing is fixed. +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 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 -Ente's clients name them (`IMG_0001.heic` and `IMG_0001.mov`). With -`--out PATH`, the image is written to `PATH` and the video beside it, with -`PATH`'s name and the video's extension; a `PATH` with the video's extension is -refused. `backup-metadata --exif` (alias `--all`) additionally fetches each -file's original through the cache and records, from it or a live photo's image, -its XMP metadata, its EXIF metadata and, for a JPEG, its dimensions. EXIF is -read with [exifreader](https://github.com/mattiasw/ExifReader) from any image -format it reads, JPEG, HEIC/HEIF, AVIF, PNG and WebP among them. The record's -`exif` field is exifreader's EXIF tag output: each tag by name, with its -`value`, `description` and `computed` value. An EXIF block exifreader finds but -reads no tag from is recorded, base64, as `exifRaw`, with the reason in -`exifError`. `collections`, `files`, `backup`, `helper list-missing-thumbnails` -and `helper fix-missing-thumbnails` take `--json` for machine-readable output. +Ente's clients name them (`IMG_0001.heic` and `IMG_0001.mov`), and each name is +cut short as above once its own extension is in it. With `--out PATH`, the image +is written to `PATH` and the video beside it, with `PATH`'s name and the video's +extension; a `PATH` with the video's extension is refused. +`backup-metadata --exif` (alias `--all`) additionally fetches each file's +original through the cache and records, from it or a live photo's image, its XMP +metadata, its EXIF metadata and, for a JPEG, its dimensions. EXIF is read with +[exifreader](https://github.com/mattiasw/ExifReader) from any image format it +reads, JPEG, HEIC/HEIF, AVIF, PNG and WebP among them. The record's `exif` field +is exifreader's EXIF tag output: each tag by name, with its `value`, +`description` and `computed` value. An EXIF block exifreader finds but reads no +tag from is recorded, base64, as `exifRaw`, with the reason in `exifError`. +`collections`, `files`, `backup`, `helper list-missing-thumbnails` and +`helper fix-missing-thumbnails` take `--json` for machine-readable output. `backup --verify` also hashes the originals already in the backup and replaces any that do not match the content hash Ente records; one it cannot replace stays as it was and goes into `failures.json` (see "Backup layout"). +`backup-metadata <dir>` writes `account.json` and, for each collection, a +directory `collections/<id>-<name>/` holding `_collection.json` and a +`<fileID>.json` for each of its files. The collection's name has unsafe +characters replaced and is cut short between two characters so that the +directory's name fits in 255 bytes; the ID keeps the directories apart. + `backup-metadata` fetches ML data in requests of up to 200 files. When a request fails, the error is logged, each of its files is written with the reason in an `mlDataError` field instead of `mlData`, and the dump goes on. A file whose ML @@ -756,9 +770,10 @@ Each original is saved at its save path, the same path `photo.savePath` gives and `photo.download()` writes (see Read surface below). The date is the photo's `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 -name as uploaded, case kept, or `.bin` when it has none or it holds anything but -letters and digits. When the date or the time zone changes, the next run saves -the original at its new path and leaves the old copy where it is. +name as uploaded, case kept, or `.bin` when it has none, it holds anything but +letters and digits, or it is more than 16 of them long. When the date or the +time zone changes, the next run saves the original at its new path and leaves +the old copy where it is. `account.json` holds the account's `email` and `userID`, as `backup-metadata` writes it. A collection's JSON holds its `id`, `name`, `type`, `ownerID`, @@ -851,11 +866,12 @@ gets its ID added as well: beside an album `x`, whose JSON is `x.json`, an album named `x.json` gets the directory `x.json (12)/`. A name too long for a file name, which most file systems limit to 255 bytes, gets its ID added too. A name with its ID added is cut short between two characters in the part before the ID, -just far enough that it fits. When a symlink's extension alone leaves no room -for the ID, the extension is cut short and the part before the ID keeps only the -bytes that cut leaves over. A collection's directory name also leaves room for -the `.json` its JSON adds. IDs never change, so a name stays the same from run -to run until such a clash appears or goes away. +just far enough that it fits. When a symlink's extension leaves no room for the +ID and the first character of the name, the extension is cut short, and the part +before the ID keeps that character and any bytes the cut leaves over. A +collection's directory name also leaves room for the `.json` its JSON adds. IDs +never change, so a name stays the same from run to run until such a clash +appears or goes away. A collection whose directory or JSON cannot be written, such as one whose directory name is taken by a file you put in `collections/`, does not stop the diff --git a/TODO.md b/TODO.md index 02a97ff..4dbe07f 100644 --- a/TODO.md +++ b/TODO.md @@ -17,15 +17,32 @@ pre-1.0 # Next Step -One bug is open: 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 has, so it waits on him. +No bug is open. Two changes sneak asked for are open: +https://git.eeqj.de/sneak/quak/issues/211 and +https://git.eeqj.de/sneak/quak/issues/212. The check of quak against a live Ente +account, https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak +has, so it waits on him. Tagging and releases are decided by sneak alone, and happen only when he declares one. # Completed Steps +- 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 diff --git a/src/backup.ts b/src/backup.ts index 32a9412..d687131 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -82,6 +82,7 @@ import { describeError } from "./errors.js"; import { MAX_NAME_BYTES, sanitizeFileName, + shortenKeepingExtension, shortenToBytes, withExtension, } from "./filename.js"; @@ -289,9 +290,10 @@ const rebuildSymlink = (linkPath: string, target: string): void => { // 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>)`, // 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 alone -// leaves no room for the ID, the extension is cut short and the part before -// the ID keeps only the bytes that cut leaves over. With `withJSON`, each +// before the ID is cut short as far as it must be. When the extension leaves +// no room for the ID and the first character of the name, the extension is +// cut short, and the part before the ID keeps that character and any bytes +// the cut leaves over (see `shortenKeepingExtension`). With `withJSON`, each // entry also has the JSON file `<name>.json` beside it, as an album does: a // name then leaves room for `.json`, and one that is another entry's JSON name // gets its ID added too. @@ -305,11 +307,9 @@ const uniqueNames = ( const maxBytes = MAX_NAME_BYTES - (opts.withJSON ? ".json".length : 0); const withID = (id: number, name: string): string => { const tag = ` (${id})`; - const fullExt = opts.beforeExtension ? extname(name) : ""; - const stem = name.slice(0, name.length - fullExt.length); - const ext = shortenToBytes(fullExt, maxBytes - Buffer.byteLength(tag)); - const room = maxBytes - Buffer.byteLength(tag + ext); - return shortenToBytes(stem, room) + tag + ext; + return opts.beforeExtension + ? shortenKeepingExtension(name, maxBytes, tag) + : shortenToBytes(name, maxBytes - Buffer.byteLength(tag)) + tag; }; const names = entries.map((e) => e.name); const suffixed = new Set<number>(); diff --git a/src/cli-commands.ts b/src/cli-commands.ts index 6e71734..c995432 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -332,8 +332,8 @@ export const getCommand = async ( const result = await photo.original(); // Default name is the file's own title, as the pre-library CLI used // (not the editedName-preferring projection title) (issue #52). - const outPath = opts.out ?? originalName(file); if (result.videoPath === undefined) { + const outPath = opts.out ?? originalName(file); copyFileSync(result.path, outPath); ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`); return 0; @@ -341,9 +341,13 @@ export const getCommand = async ( // 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 // --out, the image goes there and the video beside it. - const imageOut = - opts.out ?? withExtension(outPath, extname(result.path)); - const videoOut = withExtension(outPath, extname(result.videoPath)); + const imageExt = extname(result.path); + const videoExt = extname(result.videoPath); + const imageOut = opts.out ?? originalName(file, imageExt); + const videoOut = + opts.out === undefined + ? originalName(file, videoExt) + : withExtension(opts.out, videoExt); if (imageOut.toLowerCase() === videoOut.toLowerCase()) { ctx.stderr.write( `File ${fileID} is a live photo, and its video would also be written to ${imageOut}\n`, diff --git a/src/cli-output.ts b/src/cli-output.ts index 2bf9b21..49cbd5d 100644 --- a/src/cli-output.ts +++ b/src/cli-output.ts @@ -9,7 +9,7 @@ // `metadata.title`, and issue #52 requires that output stay byte-identical, so // the commands shape their output from the raw `EnteFile` through here. -import { sanitizeFileName } from "./filename.js"; +import { defaultFileName } from "./filename.js"; import type { EnteFile, FileType, Microseconds } from "./model/types.js"; // One row of `quak files --json`. @@ -33,11 +33,13 @@ export const fileListRow = (file: EnteFile): FileListRow => ({ export const fileListLine = (file: EnteFile): string => `${file.id}\t${file.metadata.fileType}\t${file.metadata.title}`; -// Default output path for `quak get` when `--out` is not given. The title comes -// from the server, so it is sanitized; `--out` is the user's and is used as is. -export const originalName = (file: EnteFile): string => - sanitizeFileName(file.metadata.title, `file-${file.id}`); +// Default output path for `quak get` when `--out` is not given, made from the +// title (see `defaultFileName`), with `ext` in place of its extension for a +// live photo's image or video; `--out` is the user's and is used as is. +export const originalName = (file: EnteFile, ext?: string): string => + defaultFileName(file.metadata.title, file.id, { ext }); -// Default output path for `quak get-thumb` when `--out` is not given. +// Default output path for `quak get-thumb` when `--out` is not given: `thumb_` +// and the title. export const thumbnailName = (file: EnteFile): string => - `thumb_${originalName(file)}`; + defaultFileName(file.metadata.title, file.id, { prefix: "thumb_" }); diff --git a/src/download/index.ts b/src/download/index.ts index 59b510a..bfd6b7e 100644 --- a/src/download/index.ts +++ b/src/download/index.ts @@ -16,7 +16,7 @@ import { streamTagFinal, } from "../crypto/index.js"; import { TruncatedStreamError } from "../errors.js"; -import { safeExtension, sanitizeFileName, withExtension } from "../filename.js"; +import { defaultFileName, safeExtension, withExtension } from "../filename.js"; import { withRetry } from "../retry.js"; import type { ApiClient } from "../api/client.js"; import type { EnteFile } from "../model/types.js"; @@ -365,10 +365,14 @@ const openPart = async ( // 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 // written unpacked: each part is named `destination` with the extension -// replaced by its own entry's, and the two must differ ignoring case. When the -// 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. +// replaced by its own entry's, and the two must differ ignoring case. When +// `defaultName` is set, `destination` is the name `defaultFileName` made from +// the title rather than a path the caller gave, and each part is named as +// `defaultFileName` names it with its own extension, so that the name is cut +// short to fit with that extension. When the file records a hash, +// `<imageHash>:<videoHash>` must match it, each over that part's own bytes. +// Only then are the image, then the video, renamed into place; on any failure +// neither is stored. // // The ZIP is chosen by its uploader and may expand enormously, so each part is // written as it decompresses and never held, and the ZIP is refused once the @@ -388,6 +392,7 @@ const decryptLivePhoto = async ( key: Uint8Array, onProgress: ProgressCallback | undefined, file: EnteFile, + defaultName: boolean, ): Promise<DownloadResult> => { const sliceSize = 4096; const dir = dirname(destination); @@ -486,8 +491,12 @@ const decryptLivePhoto = async ( `live photo's image and video have the same extension, ${video.ext}`, ); } - const path = withExtension(destination, image.ext); - const videoPath = withExtension(destination, video.ext); + const partPath = (ext: string): string => + defaultName + ? defaultFileName(file.metadata.title, file.id, { ext }) + : withExtension(destination, ext); + const path = partPath(image.ext); + const videoPath = partPath(video.ext); for (const part of parts) { await part.handle.sync(); await part.handle.close(); @@ -531,6 +540,9 @@ const decryptLivePhoto = async ( // 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 // 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 ( api: ApiClient, openStream: () => Promise<ReadableStream<Uint8Array>>, @@ -539,6 +551,7 @@ const fetchAndDecrypt = async ( destination: string, onProgress?: ProgressCallback, original?: EnteFile, + defaultName = false, ): Promise<DownloadResult> => withRetry(async () => { const stream = await openStream(); @@ -551,6 +564,7 @@ const fetchAndDecrypt = async ( key, onProgress, original, + defaultName, ); } const bytesWritten = await decryptToTemp( @@ -582,10 +596,11 @@ export const downloadFile = async ( outPath?: string, onProgress?: ProgressCallback, ): Promise<DownloadResult> => { - // `outPath` is the caller's and is used as is; the title is the server's - // and is sanitized so it can only name a file in the current directory. + // `outPath` is the caller's and is used as is. Without it, the file is + // written in the current directory under the name `defaultFileName` makes + // from the title, which is the server's. const resolvedPath = - outPath ?? sanitizeFileName(file.metadata.title, `file-${file.id}`); + outPath ?? defaultFileName(file.metadata.title, file.id); const header = fromBase64(file.file.decryptionHeader); return fetchAndDecrypt( api, @@ -595,6 +610,7 @@ export const downloadFile = async ( resolvedPath, onProgress, file, + outPath === undefined, ); }; @@ -604,9 +620,10 @@ export const downloadThumbnail = async ( outPath?: string, onProgress?: ProgressCallback, ): Promise<DownloadResult> => { + // As in `downloadFile`, with `thumb_` before the title. const resolvedPath = outPath ?? - `thumb_${sanitizeFileName(file.metadata.title, `file-${file.id}`)}`; + defaultFileName(file.metadata.title, file.id, { prefix: "thumb_" }); const header = fromBase64(file.thumbnail.decryptionHeader); return fetchAndDecrypt( api, diff --git a/src/filename.ts b/src/filename.ts index e9781ca..95b7503 100644 --- a/src/filename.ts +++ b/src/filename.ts @@ -5,7 +5,9 @@ // 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 // Windows device name. Such a name can still be too long for a file name, and -// `shortenToBytes` cuts one short. +// `shortenToBytes` or `shortenKeepingExtension` cuts one short. +// `defaultFileName` does both for the name a file is saved under when the +// caller gives no path. // // A path the user typed (`--out`, `outPath`) is not passed through here: the // caller is trusted, the server is not. @@ -30,11 +32,11 @@ export const sanitizeFileName = (name: string, fallback: string): string => { return RESERVED_DEVICE_NAME.test(cleaned) ? `_${cleaned}` : cleaned; }; -// The extension of `title` (".jpg"), or ".bin" when it has none or it holds -// anything but letters and digits. +// The extension of `title` (".jpg"), or ".bin" when it has none, it holds +// anything but letters and digits, or it is more than 16 of them long. export const safeExtension = (title: string): string => { const ext = extname(title); - return /^\.[A-Za-z0-9]+$/.test(ext) ? ext : ".bin"; + return /^\.[A-Za-z0-9]{1,16}$/.test(ext) ? ext : ".bin"; }; // `name` with its extension, if it has one, replaced by `ext` (".mov"). @@ -57,3 +59,53 @@ export const shortenToBytes = (name: string, maxBytes: number): string => { } 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)}`; +}; diff --git a/src/metadata-backup.ts b/src/metadata-backup.ts index 79fc4ed..9620efe 100644 --- a/src/metadata-backup.ts +++ b/src/metadata-backup.ts @@ -5,7 +5,11 @@ import type { Client } from "./client.js"; import { describeError } from "./errors.js"; import { readExifTags } from "./exif.js"; import type { Library, Photo } from "./library/index.js"; -import { sanitizeFileName } from "./filename.js"; +import { + MAX_NAME_BYTES, + sanitizeFileName, + shortenToBytes, +} from "./filename.js"; import { fetchMLDataBatch, MLDATA_BATCH_SIZE, @@ -127,7 +131,15 @@ export const runMetadataBackup = async ( const col = lib.getCollection(album.collectionID); if (!col) continue; - const dirName = `${col.id}-${sanitizeFileName(col.name, "unnamed")}`; + // The album's ID keeps the folders apart, so the name after it can be + // cut short to fit in a file name. + const prefix = `${col.id}-`; + const dirName = + prefix + + shortenToBytes( + sanitizeFileName(col.name, "unnamed"), + MAX_NAME_BYTES - prefix.length, + ); const colDir = join(outDir, "collections", dirName); mkdirSync(colDir, { recursive: true }); diff --git a/test/cli/backup.test.ts b/test/cli/backup.test.ts index 135ddfb..c6258dd 100644 --- a/test/cli/backup.test.ts +++ b/test/cli/backup.test.ts @@ -1795,12 +1795,11 @@ describe("backup album folders", () => { expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 1 }); }); - // The extensions of these titles leave no room for the ID, so each is cut - // short and the name before the ID keeps only the bytes that cut leaves - // over. File 1's extension is 303 bytes and is cut to 251, which leaves - // none. File 2's is 271 bytes and is cut to 250, since "€" takes three - // bytes, which leaves one, for the "a". Neither extension is only letters - // and digits, so both originals are saved with `.bin`, a name that fits. + // The extensions of these titles leave no room for the ID and the "a" + // before them, so each is cut short to 250 bytes, which leaves one, for + // the "a". File 1's extension is 303 bytes and file 2's 271. Neither + // extension is only letters and digits, so both originals are saved with + // `.bin`, a name that fits. it("cuts the extension of a title short when it leaves no room for the ID", async () => { const outDir = join(root, "backup"); const lib = libraryOf([ @@ -1815,7 +1814,7 @@ describe("backup album folders", () => { const result = await runBackup(lib, { downloadDirectory: outDir }); - const link = ` (1).${"L".repeat(250)}`; + const link = `a (1).${"L".repeat(249)}`; const link2 = `a (2).${"€".repeat(83)}`; expect(Buffer.byteLength(link)).toBe(255); expect(Buffer.byteLength(link2)).toBe(255); @@ -1830,9 +1829,9 @@ describe("backup album folders", () => { // Files 1 and 3 share a title, so each gets its ID before the // extension. File 2's title is too long, and its extension leaves no - // room for the ID. Were the ID put at the end of file 2's title cut - // short, its link would have file 1's name, and the link made second - // would replace the first. + // room for the ID and the "x" before it. Were the ID put at the end of + // file 2's title cut short, its link would have file 1's name, and the + // link made second would replace the first. it("keeps names unique when a title's extension leaves no room for the ID", async () => { const outDir = join(root, "backup"); const shared = `x.${"Q".repeat(245)} (2)`; @@ -1852,8 +1851,8 @@ describe("backup album folders", () => { expect(result.failed).toBe(0); expect(tree(outDir)).toEqual([ "Trip/", - `Trip/ (2).${"Q".repeat(250)} -> ${linkTo("2.bin")}`, `Trip/x (1).${"Q".repeat(245)} (2) -> ${linkTo("1.bin")}`, + `Trip/x (2).${"Q".repeat(249)} -> ${linkTo("2.bin")}`, `Trip/x (3).${"Q".repeat(245)} (2) -> ${linkTo("3.bin")}`, "Trip.json", ]); diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index 6da51e1..0c1686e 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -21,7 +21,7 @@ import { utimesSync, writeFileSync, } from "node:fs"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { tmpdir } from "node:os"; import { PassThrough } from "node:stream"; import * as jpegJs from "jpeg-js"; @@ -187,6 +187,9 @@ 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. class Output { text = ""; @@ -562,17 +565,109 @@ describe("get and get-thumb", () => { }); }); +// 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 // video, which a photo viewer can open. The account here is Vacation holding // one live photo, 300, downloaded through the real download layer // (test/live-photo.ts). describe("a live photo", () => { - // What `get` prints on stderr while its refresh fetches Vacation's files. - const FETCHED_ONE_ALBUM = "Fetched the files of 1 of 1 collections\n"; - - const livePhotoClient = async (image = IMAGE): Promise<Client> => { + const livePhotoClient = async ( + image = IMAGE, + title = "IMG_0300.HEIC", + ): Promise<Client> => { const { file: live, body } = await asLivePhoto( - file(300, 1, "IMG_0300.HEIC"), + file(300, 1, title), livePhotoZip({ "image.heic": image, "video.mov": VIDEO }), livePhotoHash(image, VIDEO), ); @@ -620,6 +715,44 @@ describe("a live photo", () => { ); }); + // 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 () => { const out = join(root, "photo.jpg"); const video = join(root, "photo.mov"); diff --git a/test/download/download.test.ts b/test/download/download.test.ts index 7333b36..5dde3a2 100644 --- a/test/download/download.test.ts +++ b/test/download/download.test.ts @@ -658,6 +658,26 @@ describe("downloadFile", () => { 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 () => { // The caller is trusted: its path is not sanitized. const { api, file } = fixtureFor( @@ -751,6 +771,28 @@ describe("downloadThumbnail", () => { expect(result.path).toBe("thumb__etc_passwd"); expect(readdirSync(dir)).toEqual(["thumb__etc_passwd"]); }); + + it("leaves room for thumb_ when it cuts a title too long for a file name short", async () => { + const plaintext = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]); + const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const filePush = + sodium.crypto_secretstream_xchacha20poly1305_init_push(key); + const { header: thumbHeader, ciphertext: thumbCipher } = + encryptFileBody(plaintext, key); + const file = buildMockEnteFile(key, filePush.header, thumbHeader); + file.metadata.title = `${"t".repeat(296)}.jpg`; + const dir = mkdtempSync(join(testDir, "long-thumb-title-")); + + const api = new ApiClient({ fetch: mockFetchForBody(thumbCipher) }); + const result = await inDirectory(dir, () => + downloadThumbnail(api, file), + ); + + const name = `thumb_${"t".repeat(245)}.jpg`; + expect(Buffer.byteLength(name)).toBe(255); + expect(result.path).toBe(name); + expect(readdirSync(dir)).toEqual([name]); + }); }); // --------------------------------------------------------------------------- @@ -1881,6 +1923,53 @@ describe("downloadFile live photos", () => { ]); }); + // A file system refuses a file name over 255 bytes. Each name is cut + // short once its own extension is in it. + const imageName = `${"t".repeat(250)}.heic`; + const videoName = `${"t".repeat(251)}.mov`; + + // The title fits, but ".heic" is one byte longer than ".jpg". + it("cuts the image's name short for a 255-byte title ending in .jpg when no outPath is given", async () => { + const title = `${"t".repeat(251)}.jpg`; + const t = setup(livePhotoZip(), { ...livePhoto, title }); + + const result = await inDirectory(t.dir, () => + downloadFile(t.api, t.file), + ); + + expect(Buffer.byteLength(title)).toBe(255); + expect(Buffer.byteLength(imageName)).toBe(255); + expect(Buffer.byteLength(videoName)).toBe(255); + expect(result.path).toBe(imageName); + expect(result.videoPath).toBe(videoName); + expect(readdirSync(t.dir).sort()).toEqual([imageName, videoName]); + }); + + it("cuts the names of the image and the video short for a 300-byte title with no extension when no outPath is given", async () => { + const t = setup(livePhotoZip(), { + ...livePhoto, + title: "t".repeat(300), + }); + + const result = await inDirectory(t.dir, () => + downloadFile(t.api, t.file), + ); + + expect(result.path).toBe(imageName); + expect(result.videoPath).toBe(videoName); + expect(readdirSync(t.dir).sort()).toEqual([imageName, videoName]); + }); + + // The caller's path is used as is; only the extension is each part's own. + it("keeps an outPath too long for a file name as given", async () => { + const t = setup(livePhotoZip(), livePhoto); + const outPath = join(t.dir, `${"t".repeat(251)}.jpg`); + + await expect(downloadFile(t.api, t.file, outPath)).rejects.toThrow( + /ENAMETOOLONG/, + ); + }); + it("gives each part its own extension from the ZIP, letters and digits only", async () => { const zip = livePhotoZip({ "image.JPG": IMAGE, "video.m-4v": VIDEO }); const t = setup(zip, livePhoto); diff --git a/test/filename/filename.test.ts b/test/filename/filename.test.ts index 4cfce05..8abed29 100644 --- a/test/filename/filename.test.ts +++ b/test/filename/filename.test.ts @@ -15,8 +15,10 @@ import { describe, expect, it } from "vitest"; import { + defaultFileName, safeExtension, sanitizeFileName, + shortenKeepingExtension, shortenToBytes, } from "../../src/filename.js"; @@ -94,6 +96,12 @@ describe("safeExtension", () => { expect(safeExtension("x.jp g")).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", () => { @@ -118,3 +126,103 @@ 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}`); + }); +}); From 92e0c6c7910382456f2baa4a790ae4d0c55fe4e6 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 18 Oct 2026 20:17:10 +0200 Subject: [PATCH 38/39] quak files takes the collection ID as an optional argument (closes #211) Mandatory arguments are positional, so `quak files` takes the collection ID as an optional argument and the `--collection` option is removed. With a collection ID it lists that collection's files. Without one it lists every file in the account, collection by collection, each file once: the library keeps a record of a file for each collection that holds it, and only the first collection's record is listed, so that collection is its `collectionID` in `--json`. The `--help` text, the README and `TODO.md` say so. Model: opus-5-5 --- README.md | 16 +++- TODO.md | 10 ++- bin/quak.ts | 13 +-- src/cli-commands.ts | 12 ++- src/cli-read.ts | 31 ++++++-- test/cli/backup-undecryptable.test.ts | 2 +- test/cli/bin.test.ts | 30 +++++-- test/cli/commands.test.ts | 109 ++++++++++++++++---------- test/cli/read.test.ts | 28 +++++++ 9 files changed, 182 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 54c4105..d7b0b85 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,11 @@ yarn quak login # List the user's collections (albums). yarn quak collections -# List files in a collection. -yarn quak files --collection 12345 +# List every file in the account. +yarn quak files + +# List the files in a collection. +yarn quak files 12345 # Download and decrypt a single file. yarn quak get 67890 --out ./photo.jpg @@ -647,7 +650,7 @@ quak login interactive or QUAK_EM quak whoami print logged-in account as JSON quak logout end the session, delete it quak collections [--json] list all collections -quak files --collection <id> [--json] list files in a collection +quak files [collectionID] [--json] list files in a collection, or every file quak get <fileID> [--out path] [--collection] download and decrypt a file quak get-thumb <fileID> [--out] [--collection] download and decrypt a thumbnail quak backup <dir> [--json] [--verify] full incremental backup @@ -676,7 +679,12 @@ answers from what did decrypt (see "Default reads vs. fresh reads"). `--cache-dir` overrides where the cache lives; without it each account gets its own directory under the per-user cache path. -An ID given to `files --collection`, `get`, `get-thumb` or +`files <collectionID>` lists the files in that collection. `files` with no +argument lists every file in the account, collection by collection, each file +once: a file in several collections is listed with the first of them, which is +its `collectionID` in `--json`. + +An ID given to `files`, `get`, `get-thumb` or `helper fix-missing-thumbnails --file` is written in decimal digits, no larger than 9007199254740991. Anything else, such as `0x10`, `1e3` or `-1`, is refused before any request with one line, `Invalid file ID: <arg>` or diff --git a/TODO.md b/TODO.md index 4dbe07f..47a8bc9 100644 --- a/TODO.md +++ b/TODO.md @@ -17,8 +17,7 @@ pre-1.0 # Next Step -No bug is open. Two changes sneak asked for are open: -https://git.eeqj.de/sneak/quak/issues/211 and +No bug is open. One change sneak asked for is open: https://git.eeqj.de/sneak/quak/issues/212. The check of quak against a live Ente account, https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak has, so it waits on him. @@ -28,6 +27,13 @@ declares one. # 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 diff --git a/bin/quak.ts b/bin/quak.ts index 77a1a95..9c31fb5 100644 --- a/bin/quak.ts +++ b/bin/quak.ts @@ -78,14 +78,15 @@ program program .command("files") - .description("List files in a collection") - .requiredOption( - "--collection <id>", - "Collection ID (from `quak collections`)", + .description("List the files in a collection, or every file in the account") + .argument( + "[collectionID]", + "Collection ID (from `quak collections`); without it, every file " + + "is listed once", ) .option("--json", "Output as JSON array") - .action((opts: { collection: string; json?: boolean }) => - run(filesCommand(context(), opts)), + .action((collectionID: string | undefined, opts: { json?: boolean }) => + run(filesCommand(context(), collectionID, opts)), ); program diff --git a/src/cli-commands.ts b/src/cli-commands.ts index c995432..119912a 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -269,15 +269,21 @@ export const collectionsCommand = async ( } }; +// Lists the files of the collection `collectionIDStr` names, or without it every +// file in the account, each once (issue #211). export const filesCommand = async ( ctx: CliContext, - opts: { collection: string; json?: boolean }, + collectionIDStr: string | undefined, + opts: { json?: boolean }, ): Promise<number> => { await init(); const client = requireSession(ctx); if (!client) return 3; - const collectionID = parseID(ctx, "collection", opts.collection); - if (collectionID === undefined) return 1; + const collectionID = + collectionIDStr === undefined + ? undefined + : parseID(ctx, "collection", collectionIDStr); + if (collectionIDStr !== undefined && collectionID === undefined) return 1; const lib = await openReadLibrary(ctx, client, opts); try { diff --git a/src/cli-read.ts b/src/cli-read.ts index e009aa3..70b1f21 100644 --- a/src/cli-read.ts +++ b/src/cli-read.ts @@ -1,6 +1,6 @@ // How the CLI's read commands obtain current data. // -// `collections`, `files --collection`, `get`, and `get-thumb` must answer for +// `collections`, `files`, `get`, and `get-thumb` must answer for // the account's state at the moment the command runs, not for whatever the // local cache last happened to hold (owner amendment, issue #36). `files`, // `get` and `get-thumb` therefore force a server round-trip through @@ -14,7 +14,10 @@ // enumeration order (`listFiles()`), the orders the pre-library CLI printed, // 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 -// metadata via `cli-output.ts`. +// metadata via `cli-output.ts`. `files` without a collection ID lists every +// file in the account once (issue #211): the library keeps a record of a file +// for each collection that holds it, and only the first collection's record is +// listed. import type { Collection, EnteFile } from "./model/types.js"; import type { Photo, PhotosAPI } from "./library/index.js"; @@ -27,6 +30,7 @@ export interface FreshReadLibrary { fresh(): Promise<unknown>; freshCollections(): Promise<Collection[]>; getCollection(id: number): Collection | undefined; + listCollections(): Collection[]; listFiles(collectionID: number): EnteFile[]; getFileByID(fileID: number): EnteFile | undefined; photos: Pick<PhotosAPI, "byID">; @@ -40,14 +44,29 @@ export const freshCollections = ( // 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 -// the caller can tell "no such collection" from "an empty collection". +// the caller can tell "no such collection" from "an empty collection". Without +// a collection ID, every file in the account, each once, as held by the first +// collection that holds it. export const freshFiles = async ( lib: FreshReadLibrary, - collectionID: number, + collectionID?: number, ): Promise<EnteFile[] | undefined> => { await lib.fresh(); - if (!lib.getCollection(collectionID)) return undefined; - return lib.listFiles(collectionID); + if (collectionID !== undefined) { + return lib.getCollection(collectionID) + ? lib.listFiles(collectionID) + : undefined; + } + const listed = new Set<number>(); + const files: EnteFile[] = []; + for (const collection of lib.listCollections()) { + for (const file of lib.listFiles(collection.id)) { + if (listed.has(file.id)) continue; + listed.add(file.id); + files.push(file); + } + } + return files; }; // One file, current as of a forced refresh, resolved to both its content diff --git a/test/cli/backup-undecryptable.test.ts b/test/cli/backup-undecryptable.test.ts index 7344ba1..bcd507c 100644 --- a/test/cli/backup-undecryptable.test.ts +++ b/test/cli/backup-undecryptable.test.ts @@ -548,7 +548,7 @@ describe("the read commands with records quak cannot decrypt", () => { expect(await collectionsCommand(context(ente), {})).toBe(0); expect(stdout.text).toBe("1\talbum\tTrip\n"); - expect(await filesCommand(context(ente), { collection: "1" })).toBe(0); + expect(await filesCommand(context(ente), "1", {})).toBe(0); expect(stdout.text).toBe("10\timage\tphoto-10.jpg\n"); }); }); diff --git a/test/cli/bin.test.ts b/test/cli/bin.test.ts index bbcbe79..18b55ab 100644 --- a/test/cli/bin.test.ts +++ b/test/cli/bin.test.ts @@ -1,6 +1,7 @@ /** - * Tests for the retry options `bin/quak.ts` loads the saved session with: - * `quak backup` gets the unattended ones, every other command the default. + * Tests for the retry options `bin/quak.ts` loads the saved session with + * (`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 * replaced by one that records the options it is given and reports no session, @@ -26,14 +27,19 @@ vi.mock("../../src/cli-session.js", () => ({ const argv = process.argv; const dir = mkdtempSync(join(tmpdir(), "quak-bin-test-")); -// Run `quak <args>` to completion. The "Not logged in" message and the exit -// are swallowed. -const quak = async (...args: string[]): Promise<void> => { - vi.spyOn(process.stderr, "write").mockImplementation(() => true); +// Run `quak <args>` to completion and return what it wrote on stderr, which is +// kept off the terminal. The exit is swallowed. +const quak = async (...args: string[]): Promise<string> => { + let written = ""; + vi.spyOn(process.stderr, "write").mockImplementation((text) => { + written += String(text); + return true; + }); vi.spyOn(process, "exit").mockImplementation(() => undefined as never); process.argv = ["node", "quak", ...args]; vi.resetModules(); await import("../../bin/quak.js"); + return written; }; afterEach(() => { @@ -69,3 +75,15 @@ describe("bin/quak.ts session loading", () => { 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/); + }); +}); diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index 0c1686e..a1c6773 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -121,25 +121,31 @@ const FILES: Record<number, EnteFile[]> = { // An original is 7 bytes and a thumbnail 3. `failID` makes that file's // original fail; `emptyThumbID` makes the server report that file's // thumbnail as empty. `withNewFile` adds new.jpg (102) to Vacation, advancing -// the collection's updationTime as the server does, and `refreshError` makes -// listing collections fail with that message. +// the collection's updationTime as the server does. `beachInWork` adds +// beach.jpg (100) to Work as well, so that one file is in both albums. +// `refreshError` makes listing collections fail with that message. const fakeClient = ( opts: { failID?: number; emptyThumbID?: number; withNewFile?: boolean; + beachInWork?: boolean; refreshError?: string; } = {}, ) => { const collections = opts.withNewFile ? [{ ...COLLECTIONS[0], updationTime: 2 }, COLLECTIONS[1]] : COLLECTIONS; - const files = opts.withNewFile - ? { - ...FILES, - 1: [...FILES[1], { ...file(102, 1, "new.jpg"), updationTime: 2 }], - } - : FILES; + let files = FILES; + if (opts.withNewFile) { + files = { + ...files, + 1: [...files[1], { ...file(102, 1, "new.jpg"), updationTime: 2 }], + }; + } + if (opts.beachInWork) { + files = { ...files, 2: [...files[2], file(100, 2, "beach.jpg")] }; + } const source: ContentSource = { original: async ({ file: f, destination }) => { if (f.id === opts.failID) throw new Error("HTTP 500 from server"); @@ -257,7 +263,7 @@ describe("session file", () => { const dir = join(root, "backup"); expect(await whoamiCommand(ctx)).toBe(3); expect(await collectionsCommand(ctx, {})).toBe(3); - expect(await filesCommand(ctx, { collection: "1" })).toBe(3); + expect(await filesCommand(ctx, "1", {})).toBe(3); expect(await getCommand(ctx, "100", {})).toBe(3); expect(await getThumbCommand(ctx, "100", {})).toBe(3); expect(await backupMetadataCommand(ctx, dir, {})).toBe(3); @@ -504,17 +510,14 @@ describe("collections", () => { describe("files", () => { it("prints one tab-separated line per file", async () => { - expect(await filesCommand(context(), { collection: "1" })).toBe(0); + expect(await filesCommand(context(), "1", {})).toBe(0); expect(stdout.text).toBe( "100\timage\tbeach.jpg\n" + "101\timage\tsunset.jpg\n", ); }); it("prints a JSON array with --json", async () => { - const code = await filesCommand(context(), { - collection: "2", - json: true, - }); + const code = await filesCommand(context(), "2", { json: true }); expect(code).toBe(0); expect(JSON.parse(stdout.text)).toEqual([ { @@ -528,11 +531,43 @@ describe("files", () => { }); it("exits 1 for an unknown collection", async () => { - expect(await filesCommand(context(), { collection: "9" })).toBe(1); + expect(await filesCommand(context(), "9", {})).toBe(1); expect(stderr.text).toBe( 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(""); + }); }); describe("get and get-thumb", () => { @@ -1265,7 +1300,7 @@ describe("backup-metadata --exif", () => { // collection list alone and stores nothing.) describe("backup-metadata and the thumbnail helpers refresh first", () => { beforeEach(async () => { - expect(await filesCommand(context(), { collection: "1" })).toBe(0); + expect(await filesCommand(context(), "1", {})).toBe(0); stdout.text = ""; stderr.text = ""; }); @@ -1324,10 +1359,10 @@ describe("backup-metadata and the thumbnail helpers refresh first", () => { }); }); -// `files --collection`, `get`, `get-thumb` and `helper fix-missing-thumbnails -// --file` take an ID only when it is written in decimal digits and is a safe -// integer. `Number()` alone reads `0x10` as 16, `1e3` as 1000 and an empty -// string as 0, so the command would act on a file the user never named. +// `files`, `get`, `get-thumb` and `helper fix-missing-thumbnails --file` take +// an ID only when it is written in decimal digits and is a safe integer. +// `Number()` alone reads `0x10` as 16, `1e3` as 1000 and an empty string as 0, +// so the command would act on a file the user never named. // Anything else is refused with one line naming it, and exit 1, before any // request. // @@ -1381,21 +1416,18 @@ describe("an ID given on the command line", () => { it("files looks up a decimal collection ID", async () => { const ctx = emptyAccount(); - expect(await filesCommand(ctx, { collection: "1" })).toBe(1); + expect(await filesCommand(ctx, "1", {})).toBe(1); expect(stderr.text).toBe("Collection 1 not found\n"); expect(requests).not.toHaveLength(0); }); - it.each(NOT_IDS)( - "files refuses --collection %j before any request", - async (arg) => { - const ctx = emptyAccount(); - expect(await filesCommand(ctx, { collection: arg })).toBe(1); - expect(stderr.text).toBe(`Invalid collection ID: ${arg}\n`); - expect(stdout.text).toBe(""); - expect(requests).toHaveLength(0); - }, - ); + it.each(NOT_IDS)("files refuses %j before any request", async (arg) => { + const ctx = emptyAccount(); + expect(await filesCommand(ctx, arg, {})).toBe(1); + expect(stderr.text).toBe(`Invalid collection ID: ${arg}\n`); + expect(stdout.text).toBe(""); + expect(requests).toHaveLength(0); + }); it("get looks up a decimal file ID", async () => { expect(await getCommand(emptyAccount(), "100", {})).toBe(1); @@ -1577,14 +1609,12 @@ describe("an account with many albums", () => { // 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, { collection: "7" })).toBe(0); + 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(), { collection: "7" })).toBe( - 0, - ); + expect(await filesCommand(accountContext(), "7", {})).toBe(0); expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n"); let progress = ""; @@ -1597,12 +1627,9 @@ describe("an account with many albums", () => { // 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(), { - collection: "7", - json: true, - }), - ).toBe(0); + expect(await filesCommand(accountContext(), "7", { json: true })).toBe( + 0, + ); expect(JSON.parse(stdout.text)).toEqual([ { diff --git a/test/cli/read.test.ts b/test/cli/read.test.ts index 08821a1..66ff369 100644 --- a/test/cli/read.test.ts +++ b/test/cli/read.test.ts @@ -93,6 +93,10 @@ class FakeLibrary implements FreshReadLibrary { return this.collections.find((c) => c.id === id); } + listCollections(): Collection[] { + return this.refreshed ? this.collections : []; + } + listFiles(collectionID: number): EnteFile[] { return this.refreshed ? this.files.filter((f) => f.collectionID === collectionID) @@ -175,6 +179,30 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => { ]); }); + // Without a collection ID, `files` lists every file in the account once + // (issue #211). File 11 is in collections 1 and 2; collection 2 is + // enumerated first, so 11 is listed once, with collection 2's record. + it("freshFiles without a collection ID lists every file once, from the first collection holding it", async () => { + const files = [ + file(10, 1, 1_700_000_000_000_000), + file(11, 1, 1_700_000_000_000_001), + file(20, 2, 1_700_000_000_000_002), + file(11, 2, 1_700_000_000_000_001), + ]; + const lib = new FakeLibrary( + [collection(2, 100), collection(1, 200)], + files, + ); + + const rows = await freshFiles(lib); + expect(lib.freshCalls).toBe(1); + expect(rows?.map((f) => [f.id, f.collectionID])).toEqual([ + [20, 2], + [11, 2], + [10, 1], + ]); + }); + it("freshFiles returns undefined for an unknown collection", async () => { const lib = new FakeLibrary([collection(1, 100)], []); const rows = await freshFiles(lib, 999); From 68d22b992729eebc341e854050bf7ff8115317f8 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 18 Oct 2026 21:46:59 +0200 Subject: [PATCH 39/39] -v raises the CLI's log level per repeat; -q prints only fatal errors (closes #212) Every line quak prints on stderr now goes through one log, in src/cli-log.ts, at one of six levels. The default, info, prints what quak printed before. -v adds each retry and the start and end of each refresh and ML data fetch; -vv adds each request with its result. -q prints only fatal lines, and is refused with -v; both are program options. Every non-zero exit logs one fatal line, so a run that exits 1 because files failed says how many and where to look. Every line reporting a failure is at error. --json changes stdout only, so backup and --json runs log their progress on stderr. withRetry and ApiClient report retries and requests through new callbacks. Model: opus-5-5 --- README.md | 97 +++++-- TODO.md | 22 +- bin/quak.ts | 48 +++- src/api/client.ts | 69 ++++- src/backup.ts | 20 +- src/cli-commands.ts | 206 +++++++-------- src/cli-log.ts | 99 +++++++ src/cli-run.ts | 21 +- src/metadata-backup.ts | 11 +- src/retry.ts | 9 +- src/thumbnails.ts | 7 +- test/api/client.test.ts | 62 +++++ test/cli/backup-end-to-end.test.ts | 9 +- test/cli/backup-undecryptable.test.ts | 21 +- test/cli/bin.test.ts | 14 +- test/cli/commands.test.ts | 116 ++++++++- test/cli/log.test.ts | 357 ++++++++++++++++++++++++++ test/cli/metadata-backup.test.ts | 30 ++- test/cli/run.test.ts | 9 +- test/retry/retry.test.ts | 31 +++ test/thumbnails/thumbnails.test.ts | 29 ++- 21 files changed, 1099 insertions(+), 188 deletions(-) create mode 100644 src/cli-log.ts create mode 100644 test/cli/log.test.ts diff --git a/README.md b/README.md index d7b0b85..06e77ea 100644 --- a/README.md +++ b/README.md @@ -137,12 +137,12 @@ matters. ### Exit codes -| Code | Meaning | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | The backup is complete: every original is at its save path, and no file failed. | -| `1` | The run finished with files in `failures.json` or albums it could not decrypt or write (see "Backup layout"), or it stopped on another error, printed as one line `quak: <message>`. The next run tries again. | -| `2` | Another backup of the same directory is running. This run sent no request and changed nothing. | -| `3` | There is no usable session: none is saved, the saved one is corrupt, or the server no longer accepts it. Run `quak login` as the user the cron job runs as. | +| Code | Meaning | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | The backup is complete: every original is at its save path, and no file failed. | +| `1` | The run finished with files in `failures.json` or albums it could not decrypt or write (see "Backup layout"), and its last line says how many, or it stopped on another error, printed as one line `quak: <message>`. The next run tries again. | +| `2` | Another backup of the same directory is running. This run sent no request and changed nothing. | +| `3` | There is no usable session: none is saved, the saved one is corrupt, or the server no longer accepts it. Run `quak login` as the user the cron job runs as. | `<message>` is the error's message, followed by the message of each error that caused it. Under Node, as `yarn quak` runs quak, `fetch` reports every failure @@ -378,6 +378,8 @@ quak/ thumbnails.ts detect + regenerate missing thumbnails client.ts high-level Client class assembled from the above cli-commands.ts the CLI's commands as functions returning exit codes + cli-log.ts the CLI's log level, and the one function the + commands write stderr lines through cli-output.ts how the CLI prints a file's title and time cli-read.ts fresh reads for the CLI's read commands cli-run.ts run a command, print its error, exit with its code @@ -456,6 +458,12 @@ A custom API endpoint is configurable for self-hosted servers via the constructor option `apiOrigin`. When set, file downloads route through `<apiOrigin>/files/download/<id>` instead of the dedicated CDN host. +The constructor option `onRequest` is called once for each request sent, a +retried request once for each attempt, with its method, its URL and the status +of the answer or the error the request failed with. The URL of a presigned +upload is given without its query, which holds the upload's signature. The CLI +logs each request with `-vv` (see "CLI surface"). + Required request headers on every authenticated call: - `X-Auth-Token`: the decrypted auth token from login. @@ -532,7 +540,9 @@ each wait is drawn at random below its ceiling. Every other command uses the defaults. A library user gets the same budget by passing `UNATTENDED_RETRY_OPTIONS` as `ApiClientOptions.retry`. `sleep` and `random` are injectable through the same option, which is how the test suite exercises the -whole policy without waiting. +whole policy without waiting. `onRetry`, given there too, is called before each +wait with the error the attempt failed with, the attempt's number and the wait; +the CLI logs it with `-v` (see "CLI surface"). Two deadlines, renewed for each attempt: @@ -646,6 +656,7 @@ thumbnails), for the user to delete if they want it gone. ``` quak [--cache-dir <path>] <command> global: local metadata/content cache location +quak [-v | -vv | -q] <command> global: log more on stderr, or only fatal errors quak login interactive or QUAK_EMAIL/QUAK_PASSWORD quak whoami print logged-in account as JSON quak logout end the session, delete it @@ -669,16 +680,66 @@ 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 -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"). +the command logs a line on stderr for each collection whose files it has +fetched, such as `Fetched the files of 3 of 120 collections`, with `--json` too; +`backup` does the same while the refresh it starts with runs. stdout holds only +the command's output. If that round-trip fails, the command prints the error on +one line and exits 1, or 3 when the server no longer accepts the saved session +(see "Session handling"). An album or file quak cannot decrypt does not fail it; +the command answers from what did decrypt (see "Default reads vs. fresh reads"). `--cache-dir` overrides where the cache lives; without it each account gets its own directory under the per-user cache path. +`-v` (`--verbose`) and `-q` (`--quiet`) set how much quak logs on stderr. Like +`--cache-dir`, they go before or after any command's name. Every line quak +prints on stderr is a log line at one of six levels, from the quietest: fatal, +error, warning, info, debug and trace. A run prints the lines at its own level +and at every quieter one. stdout holds only the command's output, at every +level. + +- Every command that exits non-zero logs a fatal line saying why, such as + `quak: <message>`, `Not logged in`, an invalid ID or a collection that does + not exist. A command that exits 1 because files or albums failed logs it last, + saying how many failed and where to look: `backup` points to `failures.json` + and its `FAILED` lines, `backup-metadata` to the `mlDataError` in the JSON of + each such file, and `helper fix-missing-thumbnails` to its `Failed files:` + list, or to its JSON with `--json`. +- An error line reports something that failed: the `FAILED` lines that `backup` + and `helper fix-missing-thumbnails` log as they work, + `ML data of file <id> could not be read` and + `ML data request for <n> file(s) failed` from `backup-metadata`, + `Could not check <title>` from both thumbnail helpers, and the `Failed files:` + list at the end of `backup` and of `helper fix-missing-thumbnails`. +- A warning reports something that did not fail but needs your attention: after + `logout`, that the cache directory still holds decrypted data. +- Every other line quak logs by default is info. + +With `--json`, a command's result on stdout takes the place of its summary on +stderr: for `backup`, the lines from `--- Backup complete ---` to the end of its +`Failed files:` list; for `helper list-missing-thumbnails`, +`No missing thumbnails found.` or `<n> file(s) with missing thumbnails:`; and +for `helper fix-missing-thumbnails`, the lines from `--- Done ---` to the end of +its `Failed files:` list. Every other line is logged as it is without `--json`. + +- With neither option the level is info: the lines quak prints as it works, such + as a refresh's progress, the files a backup fetches, its summary and the files + that failed. +- `-v` raises the level to debug, which adds each retry of a request, with why + the attempt failed and how long quak waits before the next, and the start and + end of each refresh and each ML data fetch, with the error of one that failed. +- `-vv` raises it to trace, which adds each request quak sends: its method, its + URL and the status of the answer, or the error the request failed with. The + URL of an upload of a thumbnail is logged without its query, which holds the + upload's signature. More repeats stay at trace, and `--verbose --verbose` is + `-vv`. +- `-q` lowers it to fatal: a run that exits 0 prints nothing on stderr, and one + that exits non-zero prints only its fatal lines. The prompts of `login` are + not log lines, and `-q` leaves them as they are. + +`-q` with `-v` is refused with one line, +`error: option '-q, --quiet' cannot be used with option '-v, --verbose'`, and +exit status 1. + `files <collectionID>` lists the files in that collection. `files` with no argument lists every file in the account, collection by collection, each file once: a file in several collections is listed with the first of them, which is @@ -731,7 +792,7 @@ fails, the error is logged, each of its files is written with the reason in an `mlDataError` field instead of `mlData`, and the dump goes on. A file whose ML data does not decrypt, gunzip or parse is logged and written the same way, with why in `mlDataError`. The exit code is non-zero if any file was written with -`mlDataError`. +`mlDataError`, and the last line then says how many were. `helper fix-missing-thumbnails` regenerates thumbnails for JPEG images only, because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG image @@ -828,8 +889,8 @@ reads vs. fresh reads") goes into `failures.json` with an error that starts `could not be decrypted:` and gives the reason. If a copy of it in another album decrypted, it does not: its original is stored from that copy, and the album where it does not decrypt neither links nor lists it. An album the library could -not decrypt has no name quak can read and no files it can list. A run without -`--json` prints one line for it, +not decrypt has no name quak can read and no files it can list. `quak backup` +prints one line for it on stderr, `FAILED album <id>: could not be decrypted: <reason>`, and `--json` and the `BackupResult` list its ID in `undecryptableAlbums`. Such a file in `failures.json`, or such an album, keeps the exit code at 1, run after run, @@ -883,7 +944,7 @@ appears or goes away. A collection whose directory or JSON cannot be written, such as one whose directory name is taken by a file you put in `collections/`, does not stop the -run. A run without `--json` prints `FAILED album <name>: <reason>` for it, +run. `quak backup` prints `FAILED album <name>: <reason>` for it on stderr, `--json` and the `BackupResult` list its ID in `failedAlbums`, and the exit code is 1. The other collections and `failures.json` are still written. diff --git a/TODO.md b/TODO.md index 47a8bc9..f1de6ec 100644 --- a/TODO.md +++ b/TODO.md @@ -17,16 +17,30 @@ pre-1.0 # Next Step -No bug is open. One change sneak asked for is open: -https://git.eeqj.de/sneak/quak/issues/212. The check of quak against a live Ente -account, https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak -has, so it waits on him. +No bug is open, and no change sneak asked for is open. The check of quak against +a live Ente account, https://git.eeqj.de/sneak/quak/issues/187, needs an account +only sneak has, so it waits on him. Tagging and releases are decided by sneak alone, and happen only when he declares one. # Completed Steps +- 2026-10-18: `-v`/`--verbose` raises the CLI's log level once for each repeat, + and `-q`/`--quiet` prints only fatal errors + (https://git.eeqj.de/sneak/quak/issues/212). The levels are fatal, error, + warning, info, debug and trace; the default is info, everything quak printed + on stderr before, `-v` gives debug and `-vv` trace. `src/cli-log.ts` holds the + level and the log the commands write stderr lines through. Debug adds each + retry, which `withRetry` reports through the new `onRetry`, and the start and + end of each refresh and ML data fetch; trace adds each request, which + `ApiClient` reports through the new `onRequest`. `--json` no longer keeps the + progress lines off stderr, so `backup` and `--json` runs log a refresh's + progress too. Every line that reports a failed file or album is at error, + which the progress callbacks of `backup`, `backup-metadata` and the thumbnail + helpers now say, and a command that exits 1 because files or albums failed + ends with one fatal line saying how many and where to look. + - 2026-10-18: `quak files` takes the collection ID as an optional argument instead of the `--collection` option, which is gone (https://git.eeqj.de/sneak/quak/issues/211). With no argument it lists every diff --git a/bin/quak.ts b/bin/quak.ts index 9c31fb5..be3cd53 100644 --- a/bin/quak.ts +++ b/bin/quak.ts @@ -2,8 +2,9 @@ import { stdout, stderr } from "node:process"; import { input, password } from "@inquirer/prompts"; -import { Command } from "commander"; +import { Command, Option } from "commander"; import envPaths from "env-paths"; +import { logLevel, logRequests, stderrLog, type Log } from "../src/cli-log.js"; import { init } from "../src/crypto/index.js"; import { type CliContext, @@ -29,6 +30,9 @@ const paths = envPaths("quak", { suffix: "" }); const program = new Command(); +// Program options, so they are taken before or after any command's name. +// `-v` counts its repeats, the way commander's documentation counts a repeated +// option. program .name("quak") .description("CLI for the Ente end-to-end encrypted photo service") @@ -37,21 +41,46 @@ program "--cache-dir <path>", "Directory for the local metadata/content cache " + "(default: the per-user cache directory)", - ); + ) + .option( + "-v, --verbose", + "Log more on stderr: debug lines with -v, trace lines too with -vv", + (_value: string, previous: number) => previous + 1, + 0, + ) + .addOption( + new Option( + "-q, --quiet", + "Print nothing on stderr but the error a failing command exits with", + ).conflicts("verbose"), + ) + .configureHelp({ showGlobalOptions: true }); + +interface ProgramOptions { + cacheDir?: string; + verbose: number; + quiet?: boolean; +} + +// The log for this run, at the level `-v` and `-q` ask for. +const log = (): Log => { + const { verbose, quiet } = program.opts<ProgramOptions>(); + return stderrLog(stderr, logLevel(verbose, quiet ?? false)); +}; const context = (): CliContext => ({ stdout, - stderr, + log: log(), sessionDir: paths.data, - cacheDir: program.opts<{ cacheDir?: string }>().cacheDir, - loadSession, - login: (opts) => Client.login(opts), + cacheDir: program.opts<ProgramOptions>().cacheDir, + loadSession: (path) => loadSession(path, logRequests(log())), + login: (opts) => Client.login({ ...opts, apiOptions: logRequests(log()) }), prompt: (message) => input({ message }), promptSecret: (message) => password({ message, mask: true }), }); const run = (command: Promise<number>): Promise<void> => - runCommand(command, stdout, stderr, (code) => process.exit(code)); + runCommand(command, stdout, stderr, log(), (code) => process.exit(code)); program .command("login") @@ -143,7 +172,10 @@ program { ...context(), loadSession: (path) => - loadSession(path, { retry: UNATTENDED_RETRY_OPTIONS }), + loadSession( + path, + logRequests(log(), UNATTENDED_RETRY_OPTIONS), + ), }, dir, opts, diff --git a/src/api/client.ts b/src/api/client.ts index 316f9cc..483090e 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -36,6 +36,15 @@ export interface ApiClientOptions { retry?: RetryOptions; requestTimeoutMs?: number; downloadTimeoutMs?: number; + // Called once for each request sent, with the status of its response or + // the error it failed with, for a caller that logs requests. A request + // that is retried is reported once for each attempt. + onRequest?: (request: { + method: string; + url: string; + status?: number; + error?: unknown; + }) => void; } export interface StreamOptions { @@ -159,6 +168,7 @@ export class ApiClient { private readonly retry: ResolvedRetryOptions; private readonly requestTimeoutMs: number; private readonly downloadTimeoutMs: number; + private readonly onRequest: NonNullable<ApiClientOptions["onRequest"]>; private token: string | undefined; constructor(opts?: ApiClientOptions) { @@ -180,6 +190,7 @@ export class ApiClient { opts?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; this.downloadTimeoutMs = opts?.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS; + this.onRequest = opts?.onRequest ?? (() => {}); this.token = opts?.authToken; } @@ -214,6 +225,33 @@ export class ApiClient { return h; } + // Send one request and report it to `onRequest`, with the status of its + // response or the error it failed with. `reportedURL` is the URL given to + // `onRequest`, when that must not be the one sent. + private async send( + url: string, + init: RequestInit & { method: string }, + reportedURL = url, + ): Promise<Response> { + let resp: Response; + try { + resp = await this._fetch(url, init); + } catch (err) { + this.onRequest({ + method: init.method, + url: reportedURL, + error: err, + }); + throw err; + } + this.onRequest({ + method: init.method, + url: reportedURL, + status: resp.status, + }); + return resp; + } + private async throwIfError(resp: Response): Promise<void> { if (resp.ok) return; const requestID = resp.headers.get("x-request-id") ?? undefined; @@ -257,7 +295,7 @@ export class ApiClient { const url = buildURL(this.apiOrigin, path, query); // A GET changes nothing, so it is retried under the full policy. return withRetry(async () => { - const resp = await this._fetch(url, { + const resp = await this.send(url, { method: "GET", headers: this.headers(), signal: AbortSignal.timeout(this.requestTimeoutMs), @@ -280,7 +318,7 @@ export class ApiClient { // 3xx status, which is not retried. return withRetry( async () => { - const resp = await this._fetch(url, { + const resp = await this.send(url, { method: "POST", headers: this.headers({ "Content-Type": "application/json", @@ -321,16 +359,23 @@ export class ApiClient { // object at one key in one request, so replaying it either overwrites // the same bytes or lands them for the first time. There is no partial // state to protect, hence the full policy rather than the POST rule. + // It is reported without its query, which holds the upload's + // signature: a log keeps what it is given. + const { origin, pathname } = new URL(presignedURL); await withRetry(async () => { - const resp = await this._fetch(presignedURL, { - method: "PUT", - headers: { - "Content-Type": "application/octet-stream", - "Content-Length": String(data.length), + const resp = await this.send( + presignedURL, + { + method: "PUT", + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": String(data.length), + }, + body: data, + signal: AbortSignal.timeout(this.requestTimeoutMs), }, - body: data, - signal: AbortSignal.timeout(this.requestTimeoutMs), - }); + origin + pathname, + ); if (!resp.ok) { // An ApiError, not a bare Error: without the status on the // error the upload path cannot be classified at all, and a @@ -348,7 +393,7 @@ export class ApiClient { // Same replay and redirect rules as `postJSON`, for the same reasons. return withRetry( async () => { - const resp = await this._fetch(url, { + const resp = await this.send(url, { method: "PUT", headers: this.headers({ "Content-Type": "application/json", @@ -394,7 +439,7 @@ export class ApiClient { // headers, when no bytes have arrived either. const deadline = idleDeadline(this.downloadTimeoutMs); try { - const resp = await this._fetch(url, { + const resp = await this.send(url, { method: "GET", headers: this.headers(), signal: deadline.signal, diff --git a/src/backup.ts b/src/backup.ts index d687131..37e5116 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -101,7 +101,10 @@ import type { MLData } from "./mldata-fetch.js"; import type { Collection, EnteFile, FileMetadata } from "./model/types.js"; import { hasTransportCode } from "./retry.js"; -export type ProgressCallback = (message: string) => void; +// Reports one line of the backup's progress. `level` is "error" when the line +// reports something that failed, such as a file or an album, and is left out +// for every other line. +export type ProgressCallback = (message: string, level?: "error") => void; // The lock in a backup directory, as `lockBackupDirectory` returns it. export interface BackupLock { @@ -654,7 +657,7 @@ const runLockedBackup = async ( for (const record of lib.undecryptable()) { const error = `could not be decrypted: ${record.error}`; if (record.fileID === undefined) { - log(`FAILED album ${record.collectionID}: ${error}`); + log(`FAILED album ${record.collectionID}: ${error}`, "error"); undecryptableAlbums.push(record.collectionID); } else if (scope.has(record.collectionID)) { const copies = memberships.get(record.fileID); @@ -699,6 +702,7 @@ const runLockedBackup = async ( } catch (err) { log( `FAILED verifying original ${file.metadata.title}: ${describeError(err)}`, + "error", ); recordFailure( file.id, @@ -732,6 +736,7 @@ const runLockedBackup = async ( } catch (err) { log( `FAILED original ${file.metadata.title}: ${describeError(err)}`, + "error", ); recordFailure( file.id, @@ -807,7 +812,7 @@ const runLockedBackup = async ( await lib.fetchMLData(); } catch (err) { mlDataFailure = err; - log(`FAILED ML data: ${describeError(err)}`); + log(`FAILED ML data: ${describeError(err)}`, "error"); } for (const file of distinct.values()) { if (lock.lost) throw lock.lost; @@ -868,7 +873,10 @@ const runLockedBackup = async ( downloadDirectory, ); } catch (err) { - log(`FAILED removing old album directories: ${describeError(err)}`); + log( + `FAILED removing old album directories: ${describeError(err)}`, + "error", + ); } // An album whose folder or JSON cannot be written, such as one whose @@ -897,6 +905,7 @@ const runLockedBackup = async ( } catch (err) { log( `FAILED removing old links in ${c.name}: ${describeError(err)}`, + "error", ); } @@ -915,6 +924,7 @@ const runLockedBackup = async ( } catch (err) { log( `FAILED symlink ${c.name}/${linkName}: ${describeError(err)}`, + "error", ); recordFailure( link.file.id, @@ -931,7 +941,7 @@ const runLockedBackup = async ( metaFiles, ); } catch (err) { - log(`FAILED album ${c.name}: ${describeError(err)}`); + log(`FAILED album ${c.name}: ${describeError(err)}`, "error"); failedAlbums.push(c.id); } } diff --git a/src/cli-commands.ts b/src/cli-commands.ts index 119912a..52f1b05 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -4,7 +4,10 @@ // code; a thrown error is left to the caller. Nothing here calls // `process.exit`: `bin/quak.ts` wires these to the command line, and `run` in // `cli-run.ts` prints a thrown error as one line and exits once output has -// drained. Output must stay byte-identical (see `cli-output.ts`). +// drained. Output must stay byte-identical (see `cli-output.ts`). Each line on +// stderr goes through `ctx.log` at its level (see `cli-log.ts`). Every command +// that exits non-zero logs a fatal line saying why; one that exits 1 because +// files or albums failed logs it last, saying how many and where to look. import { copyFileSync, @@ -33,6 +36,7 @@ import { originalName, thumbnailName, } from "./cli-output.js"; +import { type Log, logRefresh } from "./cli-log.js"; import { freshCollections, freshFiles, freshFile } from "./cli-read.js"; import { withExtension } from "./filename.js"; import { runMetadataBackup } from "./metadata-backup.js"; @@ -40,7 +44,8 @@ import { listMissingThumbnails, fixMissingThumbnails } from "./thumbnails.js"; export interface CliContext { stdout: { write(text: string): unknown }; - stderr: { write(text: string): unknown }; + // Writes a line on stderr at a log level, if the run's level prints it. + log: Log; // Directory holding `session.json`. sessionDir: string; // The `--cache-dir` global, or undefined to let the library pick its @@ -82,16 +87,16 @@ const requireSession = (ctx: CliContext): Client | undefined => { try { client = ctx.loadSession(sessionPath(ctx)); } catch (err) { - ctx.stderr.write( - `${err instanceof Error ? err.message : err}\n` + - `Run "quak logout" and then "quak login" to replace it.\n`, + ctx.log("fatal", err instanceof Error ? err.message : String(err)); + ctx.log( + "fatal", + `Run "quak logout" and then "quak login" to replace it.`, ); return undefined; } if (!client) { - ctx.stderr.write( - `Not logged in. Run "quak login" first.\nSession file: ${sessionPath(ctx)}\n`, - ); + ctx.log("fatal", `Not logged in. Run "quak login" first.`); + ctx.log("fatal", `Session file: ${sessionPath(ctx)}`); return undefined; } return client; @@ -107,7 +112,7 @@ const parseID = ( ): number | undefined => { const id = Number(text); if (/^[0-9]+$/.test(text) && Number.isSafeInteger(id)) return id; - ctx.stderr.write(`Invalid ${kind} ID: ${text}\n`); + ctx.log("fatal", `Invalid ${kind} ID: ${text}`); return undefined; }; @@ -128,13 +133,8 @@ const readLibraryClient = (client: Client): LibraryClient => ({ // downloading the whole account — and no refresh starts as it opens or on a // timer: each command asks for the one it needs, and `collections` needs none // (issue #210). While a refresh runs, each collection whose files it has -// fetched is 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> => +// fetched is logged, so a large account shows progress rather than nothing. +const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> => Library.open({ client: readLibraryClient(client), cacheDirectory: ctx.cacheDir, @@ -142,12 +142,7 @@ const openReadLibrary = ( refreshIntervalSeconds: Infinity, precacheThumbnails: 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`, - ); - }, + onProgress: logRefresh(ctx.log), }); export const loginCommand = async (ctx: CliContext): Promise<number> => { @@ -156,7 +151,7 @@ export const loginCommand = async (ctx: CliContext): Promise<number> => { const password = process.env.QUAK_PASSWORD ?? (await ctx.promptSecret("Password")); - ctx.stderr.write("Authenticating...\n"); + ctx.log("info", "Authenticating..."); try { const client = await ctx.login({ email, @@ -167,11 +162,12 @@ export const loginCommand = async (ctx: CliContext): Promise<number> => { saveSession(ctx.sessionDir, client.toJSON()); const info = client.whoami(); - ctx.stderr.write(`Logged in as ${info.email} (user ${info.userID})\n`); - ctx.stderr.write(`Session saved to ${sessionPath(ctx)}\n`); + ctx.log("info", `Logged in as ${info.email} (user ${info.userID})`); + ctx.log("info", `Session saved to ${sessionPath(ctx)}`); } catch (err) { - ctx.stderr.write( - `Login failed: ${err instanceof Error ? err.message : err}\n`, + ctx.log( + "fatal", + `Login failed: ${err instanceof Error ? err.message : err}`, ); return 1; } @@ -193,7 +189,7 @@ export const whoamiCommand = async (ctx: CliContext): Promise<number> => { export const logoutCommand = async (ctx: CliContext): Promise<number> => { const path = sessionPath(ctx); if (!existsSync(path)) { - ctx.stderr.write("No session found.\n"); + ctx.log("info", "No session found."); return 0; } await init(); @@ -211,16 +207,15 @@ export const logoutCommand = async (ctx: CliContext): Promise<number> => { } unlinkSync(path); if (failure === undefined) { - ctx.stderr.write("Session ended on the server.\n"); + ctx.log("info", "Session ended on the server."); } else { - ctx.stderr.write( - `Could not end the session on the server: ${failure}\n`, - ); + ctx.log("fatal", `Could not end the session on the server: ${failure}`); } - ctx.stderr.write("Session deleted.\n"); + ctx.log("info", "Session deleted."); if (cacheDir !== undefined) { - ctx.stderr.write( - `Cache directory ${cacheDir} still holds decrypted data; delete it to remove that data.\n`, + ctx.log( + "warning", + `Cache directory ${cacheDir} still holds decrypted data; delete it to remove that data.`, ); } return failure === undefined ? 0 : 1; @@ -285,7 +280,7 @@ export const filesCommand = async ( : 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 { // Force a server round-trip and list in enumeration order (issue #36 // amendment, issue #52). Each file prints from its own decrypted @@ -294,7 +289,7 @@ export const filesCommand = async ( // newest-first order. const files = await freshFiles(lib, collectionID); if (!files) { - ctx.stderr.write(`Collection ${collectionID} not found\n`); + ctx.log("fatal", `Collection ${collectionID} not found`); return 1; } @@ -330,7 +325,7 @@ export const getCommand = async ( // (issue #36 amendment, issue #52). const resolved = await freshFile(lib, fileID); if (!resolved) { - ctx.stderr.write(`File ${fileID} not found\n`); + ctx.log("fatal", `File ${fileID} not found`); return 1; } const { photo, file } = resolved; @@ -341,7 +336,7 @@ export const getCommand = async ( if (result.videoPath === undefined) { const outPath = opts.out ?? originalName(file); copyFileSync(result.path, outPath); - ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`); + ctx.log("info", `${result.bytes} bytes -> ${outPath}`); return 0; } // A live photo is written as its image and its video, each named after @@ -355,17 +350,16 @@ export const getCommand = async ( ? originalName(file, videoExt) : withExtension(opts.out, videoExt); if (imageOut.toLowerCase() === videoOut.toLowerCase()) { - ctx.stderr.write( - `File ${fileID} is a live photo, and its video would also be written to ${imageOut}\n`, + ctx.log( + "fatal", + `File ${fileID} is a live photo, and its video would also be written to ${imageOut}`, ); return 1; } copyFileSync(result.path, imageOut); copyFileSync(result.videoPath, videoOut); - ctx.stderr.write( - `${result.bytes} bytes -> ${imageOut}\n` + - `${statSync(videoOut).size} bytes -> ${videoOut}\n`, - ); + ctx.log("info", `${result.bytes} bytes -> ${imageOut}`); + ctx.log("info", `${statSync(videoOut).size} bytes -> ${videoOut}`); return 0; } finally { await lib.close(); @@ -389,7 +383,7 @@ export const getThumbCommand = async ( // (issue #36 amendment, issue #52). const resolved = await freshFile(lib, fileID); if (!resolved) { - ctx.stderr.write(`File ${fileID} not found\n`); + ctx.log("fatal", `File ${fileID} not found`); return 1; } const { photo, file } = resolved; @@ -399,7 +393,7 @@ export const getThumbCommand = async ( // used (not the projection title) (issue #52). const outPath = opts.out ?? thumbnailName(file); copyFileSync(result.path, outPath); - ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`); + ctx.log("info", `${result.bytes} bytes -> ${outPath}`); return 0; } finally { await lib.close(); @@ -421,9 +415,14 @@ export const backupMetadataCommand = async ( await lib.fresh(); const result = await runMetadataBackup(lib, client, dir, { exif: opts.exif || opts.all, - onProgress: (msg) => ctx.stderr.write(msg + "\n"), + onProgress: (msg, level) => ctx.log(level ?? "info", msg), }); - return result.filesWithMLDataError > 0 ? 1 : 0; + if (result.filesWithMLDataError === 0) return 0; + ctx.log( + "fatal", + `quak: the ML data of ${result.filesWithMLDataError} file(s) could not be read; see mlDataError in their JSON in ${dir}`, + ); + return 1; } finally { await lib.close(); } @@ -438,7 +437,7 @@ export const backupCommand = async ( const client = requireSession(ctx); if (!client) return 3; - ctx.stderr.write("Starting backup...\n"); + ctx.log("info", "Starting backup..."); // The lock is taken before the library opens and starts its refresh, so a // run refused while another backup of `dir` runs sends no request. let lock: BackupLock; @@ -446,7 +445,7 @@ export const backupCommand = async ( lock = await lockBackupDirectory(dir); } catch (err) { if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err; - ctx.stderr.write(`quak: ${(err as Error).message}\n`); + ctx.log("fatal", `quak: ${(err as Error).message}`); return 2; } try { @@ -461,45 +460,48 @@ export const backupCommand = async ( refreshIntervalSeconds: Infinity, precacheThumbnails: false, precacheOriginals: false, + onProgress: logRefresh(ctx.log), }); try { const result = await lib.backup({ downloadDirectory: dir, lock, verify: opts.verify, - onProgress: (msg) => { - if (!opts.json) ctx.stderr.write(msg + "\n"); - }, + onProgress: (msg, level) => ctx.log(level ?? "info", msg), }); if (opts.json) { ctx.stdout.write(JSON.stringify(result, null, 2) + "\n"); } else { - ctx.stderr.write("\n--- Backup complete ---\n"); - ctx.stderr.write(` Total files: ${result.totalFiles}\n`); - ctx.stderr.write(` Downloaded: ${result.downloaded}\n`); - ctx.stderr.write(` Skipped: ${result.skipped}\n`); + ctx.log("info", "\n--- Backup complete ---"); + ctx.log("info", ` Total files: ${result.totalFiles}`); + ctx.log("info", ` Downloaded: ${result.downloaded}`); + ctx.log("info", ` Skipped: ${result.skipped}`); if (opts.verify) { - ctx.stderr.write(` Verified: ${result.verified}\n`); - ctx.stderr.write(` Mismatched: ${result.mismatched}\n`); - ctx.stderr.write(` Unchecked: ${result.unchecked}\n`); + ctx.log("info", ` Verified: ${result.verified}`); + ctx.log("info", ` Mismatched: ${result.mismatched}`); + ctx.log("info", ` Unchecked: ${result.unchecked}`); } - ctx.stderr.write(` Failed: ${result.failed}\n`); + ctx.log("info", ` Failed: ${result.failed}`); if (result.errors.length > 0) { - ctx.stderr.write("\nFailed files:\n"); + ctx.log("error", "\nFailed files:"); for (const e of result.errors) { - ctx.stderr.write( - ` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`, + ctx.log( + "error", + ` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}`, ); } } } - return result.failed > 0 || - result.undecryptableAlbums.length > 0 || - result.failedAlbums.length > 0 - ? 1 - : 0; + const albums = + result.undecryptableAlbums.length + result.failedAlbums.length; + if (result.failed === 0 && albums === 0) return 0; + ctx.log( + "fatal", + `quak: ${result.failed} file(s) and ${albums} album(s) failed; see ${join(dir, "failures.json")} and the FAILED lines`, + ); + return 1; } finally { await lib.close(); } @@ -515,23 +517,24 @@ export const listMissingThumbnailsCommand = async ( await init(); const client = requireSession(ctx); if (!client) return 3; - const lib = await openReadLibrary(ctx, client, opts); + const lib = await openReadLibrary(ctx, client); try { // Refresh first so files added since the cache was written are // checked; a failed refresh throws. await lib.fresh(); - const missing = await listMissingThumbnails(lib, client, (msg) => { - if (!opts.json) ctx.stderr.write(msg + "\n"); - }); + const missing = await listMissingThumbnails(lib, client, (msg, level) => + ctx.log(level ?? "info", msg), + ); if (opts.json) { ctx.stdout.write(JSON.stringify(missing, null, 2) + "\n"); } else { if (missing.length === 0) { - ctx.stderr.write("No missing thumbnails found.\n"); + ctx.log("info", "No missing thumbnails found."); } else { - ctx.stderr.write( - `\n${missing.length} file(s) with missing thumbnails:\n`, + ctx.log( + "info", + `\n${missing.length} file(s) with missing thumbnails:`, ); for (const m of missing) { ctx.stdout.write( @@ -560,33 +563,34 @@ export const fixMissingThumbnailsCommand = async ( if (fileID === undefined) return 1; fileIDs.push(fileID); } - const lib = await openReadLibrary(ctx, client, opts); + const lib = await openReadLibrary(ctx, client); try { // Refresh first so files added since the cache was written are found; // a failed refresh throws. await lib.fresh(); if (fileIDs.length === 0) { - ctx.stderr.write("Scanning for missing thumbnails...\n"); - const missing = await listMissingThumbnails(lib, client, (msg) => { - if (!opts.json) ctx.stderr.write(msg + "\n"); - }); + ctx.log("info", "Scanning for missing thumbnails..."); + const missing = await listMissingThumbnails( + lib, + client, + (msg, level) => ctx.log(level ?? "info", msg), + ); fileIDs = missing.map((m) => m.fileID); if (fileIDs.length === 0) { - ctx.stderr.write("No missing thumbnails found.\n"); + ctx.log("info", "No missing thumbnails found."); return 0; } - ctx.stderr.write(`Found ${fileIDs.length} file(s) to fix.\n`); + ctx.log("info", `Found ${fileIDs.length} file(s) to fix.`); } const results = await fixMissingThumbnails( lib, client, fileIDs, - (msg) => { - if (!opts.json) ctx.stderr.write(msg + "\n"); - }, + (msg, level) => ctx.log(level ?? "info", msg), ); + const failed = results.filter((r) => r.status === "failed").length; if (opts.json) { ctx.stdout.write(JSON.stringify(results, null, 2) + "\n"); } else { @@ -594,30 +598,30 @@ export const fixMissingThumbnailsCommand = async ( const skipped = results.filter( (r) => r.status === "skipped", ).length; - const failed = results.filter((r) => r.status === "failed").length; - ctx.stderr.write(`\n--- Done ---\n`); - ctx.stderr.write(` Fixed: ${fixed}\n`); - ctx.stderr.write(` Skipped: ${skipped}\n`); - ctx.stderr.write(` Failed: ${failed}\n`); + ctx.log("info", `\n--- Done ---`); + ctx.log("info", ` Fixed: ${fixed}`); + ctx.log("info", ` Skipped: ${skipped}`); + ctx.log("info", ` Failed: ${failed}`); if (skipped > 0) { - ctx.stderr.write("\nSkipped:\n"); + ctx.log("info", "\nSkipped:"); for (const r of results.filter((r) => r.status === "skipped")) { - ctx.stderr.write( - ` ${r.fileID}\t${r.title}\t${r.reason}\n`, - ); + ctx.log("info", ` ${r.fileID}\t${r.title}\t${r.reason}`); } } if (failed > 0) { - ctx.stderr.write("\nFailed files:\n"); + ctx.log("error", "\nFailed files:"); for (const r of results.filter((r) => r.status === "failed")) { - ctx.stderr.write( - ` ${r.fileID}\t${r.title}\t${r.reason}\n`, - ); + ctx.log("error", ` ${r.fileID}\t${r.title}\t${r.reason}`); } } } - return results.some((r) => r.status === "failed") ? 1 : 0; + if (failed === 0) return 0; + ctx.log( + "fatal", + `quak: ${failed} file(s) failed; see the "Failed files:" list, or the JSON with --json`, + ); + return 1; } finally { await lib.close(); } diff --git a/src/cli-log.ts b/src/cli-log.ts new file mode 100644 index 0000000..88916c2 --- /dev/null +++ b/src/cli-log.ts @@ -0,0 +1,99 @@ +// The CLI's log level, and the one function the commands write stderr lines +// through. +// +// Every line quak prints on stderr is a log line at one of six levels, from +// the quietest: fatal, error, warning, info, debug, trace. A run prints the +// lines at its own level and at every quieter one. Its level is info unless +// `-v` or `-q` asks for another (see "CLI surface" in the README). Every +// command that exits non-zero logs a fatal line saying why, so even `-q`, which +// prints only fatal lines, never leaves a non-zero exit unexplained. An error +// line reports something that failed, such as a file or an album, and a +// warning something that did not fail but needs the user's attention. stdout +// carries only a command's output and is not a log. +// +// The library itself writes no lines: its layers report through callbacks, and +// the CLI logs what they report. `logRequests` gives the API client options +// that log each request and each retry, and `logRefresh` the library's +// `onProgress` that logs what a refresh and an ML data fetch report. + +import type { ApiClientOptions } from "./api/client.js"; +import { describeError } from "./errors.js"; +import type { RefreshEvent } from "./library/index.js"; +import type { RetryOptions } from "./retry.js"; + +const LEVELS = ["fatal", "error", "warning", "info", "debug", "trace"] as const; + +export type LogLevel = (typeof LEVELS)[number]; + +// Writes `line` on stderr when the run's level prints lines at `level`. +export type Log = (level: LogLevel, line: string) => void; + +// The level asked for by `-v` given `verbose` times, or by `-q`: info without +// either, debug for `-v`, trace for `-vv` and more, fatal for `-q`. +// `bin/quak.ts` refuses `-q` and `-v` together. +export const logLevel = (verbose: number, quiet: boolean): LogLevel => { + if (quiet) return "fatal"; + if (verbose === 0) return "info"; + return verbose === 1 ? "debug" : "trace"; +}; + +// The log that writes each line at `level` or quieter to `stderr`, ending it +// with a newline. +export const stderrLog = + (stderr: { write(text: string): unknown }, level: LogLevel): Log => + (lineLevel, line) => { + if (LEVELS.indexOf(lineLevel) <= LEVELS.indexOf(level)) { + stderr.write(line + "\n"); + } + }; + +// API client options that log each request at trace, with the status of its +// response or the error it failed with, and each retry at debug, with why and +// how long it waits. `retry` is the retry policy, the default when not given. +export const logRequests = ( + log: Log, + retry?: RetryOptions, +): ApiClientOptions => ({ + retry: { + ...retry, + onRetry: (err, attempt, delayMs) => + log( + "debug", + `Attempt ${attempt} failed, retrying in ${Math.round(delayMs)} ms: ${describeError(err)}`, + ), + }, + onRequest: ({ method, url, status, error }) => + log( + "trace", + `${method} ${url}: ${error === undefined ? `HTTP ${status}` : describeError(error)}`, + ), +}); + +// How a log line names each operation the library reports the start and end +// of. +const OPERATION_NAMES: Record<RefreshEvent["operation"], string> = { + refresh: "Refresh", + fetchMLData: "ML data fetch", + precacheThumbnails: "Thumbnail precache", + precacheOriginals: "Original precache", +}; + +// A library `onProgress` that logs, at info, each collection whose files a +// refresh has fetched, and at debug the start and end of each refresh and ML +// data fetch, with the error of one that failed. +export const logRefresh = + (log: Log) => + (event: RefreshEvent): void => { + if (event.status === "progress") { + log( + "info", + `Fetched the files of ${event.fetched} of ${event.total} collections`, + ); + return; + } + const error = event.error === undefined ? "" : `: ${event.error}`; + log( + "debug", + `${OPERATION_NAMES[event.operation]} ${event.status}${error}`, + ); + }; diff --git a/src/cli-run.ts b/src/cli-run.ts index 50bf822..dbc77b3 100644 --- a/src/cli-run.ts +++ b/src/cli-run.ts @@ -2,21 +2,23 @@ import type { Writable } from "node:stream"; import { ApiError } from "./api/client.js"; +import type { Log } from "./cli-log.js"; import { describeError } from "./errors.js"; // Run a command and exit with its code once stdout/stderr have drained. // Exiting before the drain can truncate piped output, and the library can keep // the event loop alive after a command returns, so a plain return could hang. -// An error the command throws is printed as one `quak: MESSAGE` line, without -// the stack trace, and exits 1. MESSAGE is the error as `describeError` gives -// it, so it carries the reason Node's `fetch` puts on `cause`. A 401 from the -// server means it no longer accepts the saved session: that prints one line -// saying to log in again and exits 3, as a missing or corrupt session file -// does. +// An error the command throws is logged as one fatal `quak: MESSAGE` line, +// without the stack trace, and exits 1. MESSAGE is the error as +// `describeError` gives it, so it carries the reason Node's `fetch` puts on +// `cause`. A 401 from the server means it no longer accepts the saved session: +// that logs one fatal line saying to log in again and exits 3, as a missing or +// corrupt session file does. export const run = async ( command: Promise<number>, stdout: Writable, stderr: Writable, + log: Log, exit: (code: number) => void, ): Promise<void> => { let code: number; @@ -24,12 +26,13 @@ export const run = async ( code = await command; } catch (err) { if (err instanceof ApiError && err.status === 401) { - stderr.write( - `quak: the saved session is no longer valid; run "quak login"\n`, + log( + "fatal", + `quak: the saved session is no longer valid; run "quak login"`, ); code = 3; } else { - stderr.write(`quak: ${describeError(err)}\n`); + log("fatal", `quak: ${describeError(err)}`); code = 1; } } diff --git a/src/metadata-backup.ts b/src/metadata-backup.ts index 9620efe..35e13e2 100644 --- a/src/metadata-backup.ts +++ b/src/metadata-backup.ts @@ -17,7 +17,10 @@ import { } from "./mldata-fetch.js"; import type { EnteFile } from "./model/types.js"; -export type ProgressCallback = (message: string) => void; +// Reports one line of the dump's progress. `level` is "error" when the line +// reports ML data that could not be read, and is left out for every other +// line. +export type ProgressCallback = (message: string, level?: "error") => void; export interface MetadataBackupOptions { exif?: boolean; @@ -195,13 +198,17 @@ export const runMetadataBackup = async ( for (const [id, payload] of payloads) mlDataMap.set(id, payload); for (const [id, err] of errors) { const reason = describeError(err); - log(`ML data of file ${id} could not be read: ${reason}`); + log( + `ML data of file ${id} could not be read: ${reason}`, + "error", + ); mlDataErrors.set(id, reason); } } catch (err) { const reason = err instanceof Error ? err.message : String(err); log( `ML data request for ${batch.length} file(s) failed: ${reason}`, + "error", ); for (const id of batch) mlDataErrors.set(id, reason); } diff --git a/src/retry.ts b/src/retry.ts index c210bf7..ba5dbcc 100644 --- a/src/retry.ts +++ b/src/retry.ts @@ -24,6 +24,9 @@ export interface RetryOptions { sleep?: (ms: number) => Promise<void>; // Injected so the jitter is reproducible under test. random?: () => number; + // Called before each wait with the error that failed attempt number + // `attempt` and how long the wait is, for a caller that logs retries. + onRetry?: (err: unknown, attempt: number, delayMs: number) => void; } export type ResolvedRetryOptions = Required<RetryOptions>; @@ -35,6 +38,7 @@ export const DEFAULT_RETRY_OPTIONS: ResolvedRetryOptions = { sleep: (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)), random: Math.random, + onRetry: () => {}, }; // For a run nobody is watching, such as `quak backup` from cron. A request that @@ -55,6 +59,7 @@ export const resolveRetryOptions = ( maxDelayMs: opts?.maxDelayMs ?? DEFAULT_RETRY_OPTIONS.maxDelayMs, sleep: opts?.sleep ?? DEFAULT_RETRY_OPTIONS.sleep, random: opts?.random ?? DEFAULT_RETRY_OPTIONS.random, + onRetry: opts?.onRetry ?? DEFAULT_RETRY_OPTIONS.onRetry, }); // Transport failures: the request did not complete, for reasons below HTTP. @@ -234,7 +239,9 @@ export const withRetry = async <T>( // and its status intact. throw err; } - await policy.sleep(backoffMs(attempt, policy)); + const delayMs = backoffMs(attempt, policy); + policy.onRetry(err, attempt, delayMs); + await policy.sleep(delayMs); } } }; diff --git a/src/thumbnails.ts b/src/thumbnails.ts index bb51f1e..0205296 100644 --- a/src/thumbnails.ts +++ b/src/thumbnails.ts @@ -49,7 +49,10 @@ export interface ThumbnailFixResult { reason?: string; } -export type ProgressCallback = (message: string) => void; +// Reports one line of progress. `level` is "error" when the line reports a +// file that could not be checked or fixed, and is left out for every other +// line. +export type ProgressCallback = (message: string, level?: "error") => void; // Enumerate every file the library knows about, newest album first, each file // once, and report those whose server-side thumbnail is missing. "Missing" is @@ -111,6 +114,7 @@ export const listMissingThumbnails = async ( } else { log( `[${album.name}] Could not check ${photo.title}: ${err instanceof Error ? err.message : String(err)} (not reported as missing)`, + "error", ); } } @@ -359,6 +363,7 @@ export const fixMissingThumbnails = async ( } catch (err) { log( `[${collectionName}] FAILED ${title}: ${err instanceof Error ? err.message : err}`, + "error", ); results.push({ fileID, diff --git a/test/api/client.test.ts b/test/api/client.test.ts index 68ab7cf..a708d31 100644 --- a/test/api/client.test.ts +++ b/test/api/client.test.ts @@ -1222,3 +1222,65 @@ describe("ApiClient non-idempotent requests", () => { } }); }); + +// `onRequest` hears of each request the client sends, as its response arrives +// or as it fails. The CLI logs these with `-vv`. A retried request is reported +// once for each attempt. +describe("ApiClient onRequest", () => { + it("reports each request with its status, or with the error it failed with", async () => { + const refused = new TypeError("fetch failed"); + const { fetch } = scriptedFetch( + refused, + textResponse("unavailable", 503), + jsonResponse({ collections: [] }), + jsonResponse({}), + streamResponse(new Uint8Array([1, 2, 3])), + ); + const requests: unknown[] = []; + const client = new ApiClient({ + fetch, + retry: noWait, + onRequest: (request) => requests.push(request), + }); + + await client.getJSON("/collections/v2", { sinceTime: 0 }); + await client.postJSON("/users/logout", {}); + await readAll(await client.getFileStream(7)); + + const collections = "https://api.ente.io/collections/v2?sinceTime=0"; + expect(requests).toEqual([ + { method: "GET", url: collections, error: refused }, + { method: "GET", url: collections, status: 503 }, + { method: "GET", url: collections, status: 200 }, + { + method: "POST", + url: "https://api.ente.io/users/logout", + status: 200, + }, + { + method: "GET", + url: "https://files.ente.io/?fileID=7", + status: 200, + }, + ]); + }); + + it("reports a presigned upload without its query, which holds the upload's signature", async () => { + const { fetch } = scriptedFetch(new Response(null, { status: 200 })); + const requests: unknown[] = []; + const client = new ApiClient({ + fetch, + retry: noWait, + onRequest: (request) => requests.push(request), + }); + + await client.putFile( + "https://s3.example/obj?X-Amz-Signature=abc", + new Uint8Array([1, 2]), + ); + + expect(requests).toEqual([ + { method: "PUT", url: "https://s3.example/obj", status: 200 }, + ]); + }); +}); diff --git a/test/cli/backup-end-to-end.test.ts b/test/cli/backup-end-to-end.test.ts index 80714ae..46895e3 100644 --- a/test/cli/backup-end-to-end.test.ts +++ b/test/cli/backup-end-to-end.test.ts @@ -54,6 +54,7 @@ import { saveSession, type CliContext, } from "../../src/cli-commands.js"; +import { stderrLog } from "../../src/cli-log.js"; import { run } from "../../src/cli-run.js"; import { loadSession } from "../../src/cli-session.js"; import { Client } from "../../src/client.js"; @@ -303,9 +304,10 @@ const backup = async ( const prompted = async (): Promise<never> => { throw new Error("quak backup prompted"); }; + const log = stderrLog(stderr, "info"); const ctx: CliContext = { stdout, - stderr, + log, sessionDir, cacheDir: `${dir}-cache`, loadSession: (path) => @@ -319,7 +321,7 @@ const backup = async ( promptSecret: prompted, }; const code = await new Promise<number>((resolve) => { - void run(backupCommand(ctx, dir, opts), stdout, stderr, resolve); + void run(backupCommand(ctx, dir, opts), stdout, stderr, log, resolve); }); return { code, stderr: printed, stdout: output }; }; @@ -1104,6 +1106,9 @@ describe("a backup whose lock is lost", () => { expect(code).toBe(1); expect(stderr).toBe( "Starting backup...\n" + + "Fetched the files of 1 of 3 collections\n" + + "Fetched the files of 2 of 3 collections\n" + + "Fetched the files of 3 of 3 collections\n" + "Refreshing library...\n" + "Fetching original beach.jpg (100)...\n" + "Fetching original IMG_0002.HEIC (300)...\n" + diff --git a/test/cli/backup-undecryptable.test.ts b/test/cli/backup-undecryptable.test.ts index bcd507c..665becb 100644 --- a/test/cli/backup-undecryptable.test.ts +++ b/test/cli/backup-undecryptable.test.ts @@ -42,6 +42,7 @@ import { saveSession, type CliContext, } from "../../src/cli-commands.js"; +import { stderrLog } from "../../src/cli-log.js"; import { loadSession } from "../../src/cli-session.js"; import { Client, type ClientSnapshot } from "../../src/client.js"; import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js"; @@ -304,7 +305,7 @@ const context = (ente: FakeEnte): CliContext => { saveSession(join(root, "session"), ente.snapshot()); return { stdout, - stderr, + log: stderrLog(stderr, "info"), sessionDir: join(root, "session"), cacheDir: join(root, "cache"), loadSession: (path) => @@ -370,6 +371,24 @@ describe("quak backup with records it cannot decrypt", () => { expect(stderr.text).toContain(" Failed: 2\n"); }); + // The album is an error line. The last line is fatal and counts the files + // and the album, so `-q`, which prints fatal lines only, still says why + // the backup exits 1. + it("logs the album at error, and ends with one fatal line counting the files and the album", async () => { + const ctx = { + ...context(brokenAccount()), + log: stderrLog(stderr, "error"), + }; + expect(await backupCommand(ctx, backupDir(), {})).toBe(1); + + expect(stderr.text).toMatch( + /^FAILED album 2: could not be decrypted: /, + ); + expect(stderr.text.split("\n").at(-2)).toBe( + `quak: 2 file(s) and 1 album(s) failed; see ${join(backupDir(), "failures.json")} and the FAILED lines`, + ); + }); + it("names each album it cannot decrypt in the --json result", async () => { expect(await backup(brokenAccount(), { json: true })).toBe(1); diff --git a/test/cli/bin.test.ts b/test/cli/bin.test.ts index 18b55ab..c24726c 100644 --- a/test/cli/bin.test.ts +++ b/test/cli/bin.test.ts @@ -1,7 +1,8 @@ /** * Tests for the retry options `bin/quak.ts` loads the saved session with * (`quak backup` gets the unattended ones, every other command the default), - * and for `quak files` taking its collection ID as an optional argument. + * each with the callbacks that log its requests and retries, and for + * `quak files` taking its collection ID as an optional argument. * * Each test runs `bin/quak.ts`, as the smoke test does, with its session loader * replaced by one that records the options it is given and reports no session, @@ -65,14 +66,21 @@ describe("bin/quak.ts session loading", () => { attempts, baseDelayMs, maxDelayMs, + onRetry: expect.any(Function), }), + onRequest: expect.any(Function), }, ]); }); - it("loads the session for another command with the default options", async () => { + it("loads the session for another command with the default retry options", async () => { await quak("collections"); - expect(loaded).toEqual([undefined]); + expect(loaded).toEqual([ + { + retry: { onRetry: expect.any(Function) }, + onRequest: expect.any(Function), + }, + ]); }); }); diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index a1c6773..3269fdb 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -51,6 +51,7 @@ import { listMissingThumbnailsCommand, fixMissingThumbnailsCommand, } from "../../src/cli-commands.js"; +import { stderrLog } from "../../src/cli-log.js"; import { run } from "../../src/cli-run.js"; import { loadSession } from "../../src/cli-session.js"; import { @@ -208,9 +209,11 @@ let root: string; let stdout: Output; let stderr: Output; +// The commands log at the default level, info, as `quak` without `-v` or `-q` +// does; the levels have their own tests, in `log.test.ts`. const context = (client: Client | null = fakeClient()): CliContext => ({ stdout, - stderr, + log: stderrLog(stderr, "info"), sessionDir: join(root, "session"), cacheDir: join(root, "cache"), loadSession: () => client, @@ -566,7 +569,7 @@ describe("files", () => { [101, 1], [200, 2], ]); - expect(stderr.text).toBe(""); + expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS); }); }); @@ -910,6 +913,40 @@ describe("backup", () => { ); }); + // A failed file is an error line both where the backup fails it and in + // the list at the end. The last line is fatal, so `-q`, which prints + // fatal lines only, still says why the backup exits 1. + it("logs a failed file at error, and ends with one fatal line counting the failures", async () => { + const dir = join(root, "backup"); + const last = `quak: 1 file(s) and 0 album(s) failed; see ${join(dir, "failures.json")} and the FAILED lines\n`; + const ctx = context(fakeClient({ failID: 101 })); + expect( + await backupCommand( + { ...ctx, log: stderrLog(stderr, "error") }, + dir, + {}, + ), + ).toBe(1); + expect(stderr.text).toBe( + "FAILED original sunset.jpg: HTTP 500 from server\n" + + "\nFailed files:\n" + + " [Vacation] sunset.jpg (id 101): HTTP 500 from server\n" + + last, + ); + + stderr.text = ""; + expect( + await backupCommand( + { ...ctx, log: stderrLog(stderr, "fatal") }, + dir, + { json: true }, + ), + ).toBe(1); + expect(stderr.text).toBe(last); + }); + + // `--json` shapes stdout only: the log lines stay on stderr, and the JSON + // takes the place of the summary. it("prints the result as JSON with --json, still exiting 1 on a failure", async () => { const ctx = context(fakeClient({ failID: 101 })); const code = await backupCommand(ctx, join(root, "backup"), { @@ -924,7 +961,31 @@ describe("backup", () => { failed: 1, }); expect(result.errors[0].fileID).toBe(101); - expect(stderr.text).toBe("Starting backup...\n"); + expect(stderr.text).toContain( + "Fetching original sunset.jpg (101)...\n", + ); + expect(stderr.text).not.toContain("Backup complete"); + }); + + // On an empty cache the library's first refresh fetches the files of + // every album before the backup can start, which on a large account takes + // a while; each album it has fetched the files of is counted on stderr, + // with `--json` too. + it("counts on stderr each album whose files its first refresh fetched, with --json too", async () => { + const start = + "Starting backup...\n" + + FETCHED_BOTH_ALBUMS + + "Refreshing library...\n"; + for (const json of [false, true]) { + stderr.text = ""; + const dir = mkdtempSync(join(root, "backup-")); + const ctx = { + ...context(), + cacheDir: mkdtempSync(join(root, "cache-")), + }; + expect(await backupCommand(ctx, dir, { json })).toBe(0); + expect(stderr.text.slice(0, start.length)).toBe(start); + } }); it("--verify downloads again an original that does not match its hash, prints the counts, and exits 0", async () => { @@ -1014,6 +1075,7 @@ describe("backup", () => { backupCommand(ctx, dir, {}), new PassThrough(), runStderr, + stderrLog(runStderr, "info"), resolve, ); }); @@ -1254,7 +1316,9 @@ describe("helper list-missing-thumbnails", () => { expect(stderr.text).toContain("No missing thumbnails found.\n"); }); - it("prints a JSON array with --json and no progress", async () => { + // `--json` shapes stdout only: the progress stays on stderr, and the JSON + // takes the place of the count and the list. + it("prints a JSON array with --json, and its progress on stderr", async () => { const ctx = context(fakeClient({ emptyThumbID: 200 })); expect(await listMissingThumbnailsCommand(ctx, { json: true })).toBe(0); expect(JSON.parse(stdout.text)).toEqual([ @@ -1265,7 +1329,37 @@ describe("helper list-missing-thumbnails", () => { reason: "empty thumbnail (0 bytes)", }, ]); - expect(stderr.text).toBe(""); + expect(stderr.text).toContain(FETCHED_BOTH_ALBUMS); + expect(stderr.text).toContain("[Work] Checking thumbnails...\n"); + expect(stderr.text).not.toContain("with missing thumbnails"); + }); +}); + +describe("helper fix-missing-thumbnails", () => { + // File 999 is in no album, so fixing it fails. The list of failed files + // is at error, and the last line is fatal, so `-q`, which prints fatal + // lines only, still says why the command exits 1. + it("lists a failed file at error, and ends with one fatal line counting the failures", async () => { + const last = `quak: 1 file(s) failed; see the "Failed files:" list, or the JSON with --json\n`; + const ctx = { ...context(), log: stderrLog(stderr, "error") }; + expect(await fixMissingThumbnailsCommand(ctx, { file: ["999"] })).toBe( + 1, + ); + expect(stderr.text).toBe( + "\nFailed files:\n" + + " 999\tunknown\tfile not found in any collection\n" + + last, + ); + + stderr.text = ""; + const quiet = { ...context(), log: stderrLog(stderr, "fatal") }; + expect( + await fixMissingThumbnailsCommand(quiet, { + file: ["999"], + json: true, + }), + ).toBe(1); + expect(stderr.text).toBe(last); }); }); @@ -1624,9 +1718,9 @@ describe("an account with many albums", () => { 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 () => { + // `--json` shapes stdout only, so the refresh's progress is still counted + // on stderr. + it("files --json counts on stderr each album whose files its refresh fetched", async () => { expect(await filesCommand(accountContext(), "7", { json: true })).toBe( 0, ); @@ -1640,6 +1734,10 @@ describe("an account with many albums", () => { collectionID: 7, }, ]); - expect(stderr.text).toBe(""); + let progress = ""; + for (let n = 1; n <= ALBUMS; n++) { + progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`; + } + expect(stderr.text).toBe(progress); }); }); diff --git a/test/cli/log.test.ts b/test/cli/log.test.ts new file mode 100644 index 0000000..137c983 --- /dev/null +++ b/test/cli/log.test.ts @@ -0,0 +1,357 @@ +/** + * Tests for the CLI's log levels, set with `-v`/`--verbose` and `-q`/`--quiet` + * (`src/cli-log.ts`, issue #212). + * + * Every line quak prints on stderr is a log line at one of six levels, from + * the quietest: fatal, error, warning, info, debug, trace. A run prints the + * lines at its own level and at every quieter one. The level is info unless + * asked otherwise: `-v` raises it to debug, `-vv` to trace, and more repeats + * stay at trace. `-q` lowers it to fatal, so a run prints only the line a + * command prints as it fails with a non-zero exit code. stdout holds the + * command's output at every level. + * + * Most tests run `bin/quak.ts`, as the smoke test does, against an account of + * one album holding one photo: the saved session is in a temporary directory, + * and `fetch` is a fake of the Ente server that serves the album and the photo + * encrypted as Ente's clients encrypt them. Each run has a cache directory of + * its own, so its refresh fetches the album's files, as on a first run. + */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import sodium from "libsodium-wrappers-sumo"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; + +import { ApiClient } from "../../src/api/client.js"; +import { saveSession } from "../../src/cli-commands.js"; +import { logRefresh, logRequests, type Log } from "../../src/cli-log.js"; +import { init, toBase64 } from "../../src/crypto/index.js"; +import { encryptSecretbox, encryptStreamBlob } from "../ente-server.js"; + +// `bin/quak.ts` reads the saved session from the data directory env-paths +// gives it, which here is a temporary directory made before the first run. +const paths = vi.hoisted(() => ({ data: "" })); +vi.mock("env-paths", () => ({ default: () => paths })); + +const USER_ID = 42; +const root = mkdtempSync(join(tmpdir(), "quak-log-test-")); +const argv = process.argv; + +class ExitCalled extends Error {} + +// Run `quak <args>` to its exit, with a cache of its own, and return its exit +// code and what it wrote on stdout and on stderr, which are kept off the +// terminal. `process.exit` is stubbed to throw, so nothing runs after it, as +// nothing does after a real exit. +const quak = async ( + ...args: string[] +): Promise<{ code: number; stdout: string; stderr: string }> => { + let stdout = ""; + let stderr = ""; + let code = NaN; + vi.spyOn(process.stdout, "write").mockImplementation((text) => { + stdout += String(text); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((text) => { + stderr += String(text); + return true; + }); + vi.spyOn(process, "exit").mockImplementation((exitCode) => { + code = Number(exitCode ?? 0); + throw new ExitCalled(); + }); + const cache = mkdtempSync(join(root, "cache-")); + process.argv = ["node", "quak", "--cache-dir", cache, ...args]; + vi.resetModules(); + await expect(import("../../bin/quak.js")).rejects.toBeInstanceOf( + ExitCalled, + ); + return { code, stdout, stderr }; +}; + +// Album 1, "Trips", of user 42, holding photo 100, "beach.jpg". The album's +// key is encrypted with the account's master key, the photo's key with the +// album's, and the photo's metadata with its own key. Only the session and +// these two records are needed: the tests never log in. +beforeAll(async () => { + await init(); + await sodium.ready; + const masterKey = sodium.crypto_secretbox_keygen(); + const keyPair = sodium.crypto_box_keypair(); + paths.data = join(root, "session"); + saveSession(paths.data, { + email: "log@example.com", + userID: USER_ID, + token: "token", + masterKey: toBase64(masterKey), + secretKey: toBase64(keyPair.privateKey), + publicKey: toBase64(keyPair.publicKey), + }); + + const albumKey = sodium.crypto_secretbox_keygen(); + const albumKeyBox = encryptSecretbox(albumKey, masterKey); + const name = encryptSecretbox(new TextEncoder().encode("Trips"), albumKey); + const album = { + id: 1, + owner: { id: USER_ID }, + encryptedKey: toBase64(albumKeyBox.ciphertext), + keyDecryptionNonce: toBase64(albumKeyBox.nonce), + encryptedName: toBase64(name.ciphertext), + nameDecryptionNonce: toBase64(name.nonce), + type: "album", + updationTime: 1, + }; + const photoKey = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const photoKeyBox = encryptSecretbox(photoKey, albumKey); + const metadata = encryptStreamBlob( + new TextEncoder().encode( + JSON.stringify({ + title: "beach.jpg", + fileType: 0, + creationTime: 1000, + modificationTime: 1000, + }), + ), + photoKey, + ); + const photo = { + id: 100, + collectionID: 1, + ownerID: USER_ID, + encryptedKey: toBase64(photoKeyBox.ciphertext), + keyDecryptionNonce: toBase64(photoKeyBox.nonce), + metadata: { + encryptedData: toBase64(metadata.ciphertext), + decryptionHeader: toBase64(metadata.header), + }, + // These tests fetch no original and no thumbnail. + file: { decryptionHeader: "" }, + thumbnail: { decryptionHeader: "" }, + updationTime: 1, + }; + + const json = (body: unknown): Response => + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }); + vi.stubGlobal("fetch", async (input: string) => { + const { pathname } = new URL(input); + if (pathname === "/collections/v2") { + return json({ collections: [album] }); + } + if (pathname === "/collections/v2/diff") { + return json({ diff: [photo], hasMore: false }); + } + return new Response("not found", { status: 404 }); + }); +}); + +afterEach(() => { + process.argv = argv; + vi.restoreAllMocks(); +}); + +afterAll(() => { + vi.unstubAllGlobals(); + rmSync(root, { recursive: true, force: true }); +}); + +// What `quak files` writes on stdout at every level. +const FILES = "100\timage\tbeach.jpg\n"; + +// What it writes on stderr at each level. Info is the refresh's progress, one +// line for the one album whose files it fetched. Debug adds the start and end +// of the refresh, and trace each request with the status of its answer. +const INFO = "Fetched the files of 1 of 1 collections\n"; +const DEBUG = "Refresh started\n" + INFO + "Refresh done\n"; +const TRACE = + "Refresh started\n" + + "GET https://api.ente.io/collections/v2?sinceTime=0: HTTP 200\n" + + "GET https://api.ente.io/collections/v2/diff?collectionID=1&sinceTime=0: HTTP 200\n" + + INFO + + "Refresh done\n"; + +describe("the log level", () => { + it("is info by default", async () => { + expect(await quak("files")).toEqual({ + code: 0, + stdout: FILES, + stderr: INFO, + }); + }); + + it("is debug with -v", async () => { + expect(await quak("-v", "files")).toEqual({ + code: 0, + stdout: FILES, + stderr: DEBUG, + }); + }); + + it("is trace with -vv", async () => { + expect(await quak("-vv", "files")).toEqual({ + code: 0, + stdout: FILES, + stderr: TRACE, + }); + }); + + it("stays at trace with -vvv", async () => { + expect((await quak("-vvv", "files")).stderr).toBe(TRACE); + }); + + it("counts --verbose --verbose as -vv", async () => { + expect((await quak("--verbose", "--verbose", "files")).stderr).toBe( + TRACE, + ); + }); + + // Both are options of the program, taken before or after the command's + // name, as `--cache-dir` is. + it("is set by an option after the command's name too", async () => { + expect((await quak("files", "-v")).stderr).toBe(DEBUG); + expect((await quak("files", "--json", "-vv")).stderr).toBe(TRACE); + expect((await quak("files", "-q")).stderr).toBe(""); + }); +}); + +describe("-q", () => { + it("prints nothing on stderr when the command works", async () => { + expect(await quak("-q", "files")).toEqual({ + code: 0, + stdout: FILES, + stderr: "", + }); + }); + + // The refresh's progress is left out; the line saying why the command + // failed is the fatal one, and it is printed. + it("prints only the line a failing command exits with", async () => { + expect(await quak("-q", "files", "9")).toEqual({ + code: 1, + stdout: "", + stderr: "Collection 9 not found\n", + }); + }); + + it("is refused with -v, in one line, before the command runs", async () => { + for (const args of [ + ["-q", "-v", "files"], + ["files", "--verbose", "--quiet"], + ]) { + const { code, stdout, stderr } = await quak(...args); + expect(code).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toBe( + "error: option '-q, --quiet' cannot be used with option '-v, --verbose'\n", + ); + } + }); +}); + +// `--help` lists both options, and each command's help lists them too, since +// each command takes them. +describe("--help", () => { + it("lists -v and -q for the program and for each command", async () => { + for (const args of [["--help"], ["files", "--help"]]) { + const { stdout } = await quak(...args); + expect(stdout).toContain("-v, --verbose"); + expect(stdout).toContain("-q, --quiet"); + } + }); +}); + +// The library writes no lines itself. Its layers report through callbacks, +// and the CLI logs what they report. +describe("what the library reports", () => { + const collect = (): { log: Log; lines: string[] } => { + const lines: string[] = []; + return { log: (level, line) => lines.push(`${level}: ${line}`), lines }; + }; + + // A request answered 503 and retried: each attempt is a request, logged + // at trace with its status, and the retry is logged at debug with how + // long it waits and why. + it("logs each request at trace and each retry at debug", async () => { + const { log, lines } = collect(); + const answers = [ + new Response("unavailable", { status: 503 }), + new Response("{}", { + headers: { "content-type": "application/json" }, + }), + ]; + const client = new ApiClient({ + ...logRequests(log, { + sleep: async () => {}, + random: () => 0.5, + }), + fetch: async () => answers.shift()!, + }); + + await client.getJSON("/collections/v2", { sinceTime: 0 }); + + const url = "https://api.ente.io/collections/v2?sinceTime=0"; + expect(lines).toEqual([ + `trace: GET ${url}: HTTP 503`, + "debug: Attempt 1 failed, retrying in 250 ms: HTTP 503", + `trace: GET ${url}: HTTP 200`, + ]); + }); + + it("logs a request that failed with its error", async () => { + const { log, lines } = collect(); + const client = new ApiClient({ + ...logRequests(log, { attempts: 1 }), + fetch: async () => { + throw new TypeError("fetch failed", { + cause: new Error("connect ECONNREFUSED 1.2.3.4:443"), + }); + }, + }); + + await expect(client.getJSON("/collections/v2")).rejects.toThrow(); + + expect(lines).toEqual([ + "trace: GET https://api.ente.io/collections/v2: fetch failed: connect ECONNREFUSED 1.2.3.4:443", + ]); + }); + + // Each operation is named in plain words, as the README names it. + it("logs a refresh's progress at info, and its start and end at debug", () => { + const { log, lines } = collect(); + const onProgress = logRefresh(log); + + onProgress({ operation: "refresh", status: "started" }); + onProgress({ + operation: "refresh", + status: "progress", + fetched: 1, + total: 2, + }); + onProgress({ + operation: "refresh", + status: "failed", + error: "HTTP 503", + }); + onProgress({ operation: "fetchMLData", status: "started" }); + onProgress({ operation: "fetchMLData", status: "done", fetched: 3 }); + + expect(lines).toEqual([ + "debug: Refresh started", + "info: Fetched the files of 1 of 2 collections", + "debug: Refresh failed: HTTP 503", + "debug: ML data fetch started", + "debug: ML data fetch done", + ]); + }); +}); diff --git a/test/cli/metadata-backup.test.ts b/test/cli/metadata-backup.test.ts index 0902e44..0b75804 100644 --- a/test/cli/metadata-backup.test.ts +++ b/test/cli/metadata-backup.test.ts @@ -47,6 +47,7 @@ import { type MetadataBackupOptions, } from "../../src/metadata-backup.js"; import { backupMetadataCommand } from "../../src/cli-commands.js"; +import { stderrLog, type LogLevel } from "../../src/cli-log.js"; import { buildAccount, buildMetaFetch, @@ -490,9 +491,12 @@ describe("quak backup-metadata", () => { }); describe("quak backup-metadata when a file's ML data cannot be read", () => { - // Run the CLI command against `fetch`, the mock unless given, and return - // its exit code, stderr and output directory. - const runCommand = async (fetch = buildMetaFetch(mock)) => { + // Run the CLI command against `fetch`, the mock unless given, logging at + // `level`, and return its exit code, stderr and output directory. + const runCommand = async ( + fetch = buildMetaFetch(mock), + level: LogLevel = "info", + ) => { const client = await Client.login({ email: TEST_EMAIL, password: TEST_PASSWORD, @@ -506,7 +510,10 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => { const code = await backupMetadataCommand( { stdout: { write: () => true }, - stderr: { write: (text: string) => (stderr += text) }, + log: stderrLog( + { write: (text: string) => (stderr += text) }, + level, + ), sessionDir: testDir, cacheDir: mkdtempSync(join(testDir, "cache-")), loadSession: () => client, @@ -517,13 +524,16 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => { return { code, stderr, outDir }; }; + // The failed request is logged at error, so it is the first line printed + // at that level. it("writes every file, marks the failed batch's files, and exits 1", async () => { const { code, stderr, outDir } = await runCommand( buildMetaFetch(mock, 200), + "error", ); expect(code).toBe(1); - expect(stderr).toContain("ML data request for 1 file(s) failed"); + expect(stderr).toMatch(/^ML data request for 1 file\(s\) failed: /); const ok = JSON.parse( readFileSync( @@ -546,7 +556,9 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => { }); // The server sends ML data for diagram.png encrypted under a key that is - // not the file's, so quak cannot decrypt it. + // not the file's, so quak cannot decrypt it. That is logged at error, and + // the last line is fatal, so `-q`, which prints fatal lines only, still + // says why the command exits 1. it("writes the reason for ML data that does not decrypt, and exits 1", async () => { const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const { header, ciphertext } = encryptBlob( @@ -564,12 +576,14 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => { }, }, }), + "error", ); expect(code).toBe(1); const reason = "secretstream chunk authentication failed"; - expect(stderr).toContain( - `ML data of file 200 could not be read: ${reason}\n`, + expect(stderr).toBe( + `ML data of file 200 could not be read: ${reason}\n` + + `quak: the ML data of 1 file(s) could not be read; see mlDataError in their JSON in ${outDir}\n`, ); const failed = JSON.parse( readFileSync( diff --git a/test/cli/run.test.ts b/test/cli/run.test.ts index 27db619..e070d2c 100644 --- a/test/cli/run.test.ts +++ b/test/cli/run.test.ts @@ -6,6 +6,7 @@ import { PassThrough } from "node:stream"; import { describe, it, expect } from "vitest"; import { ApiError } from "../../src/api/client.js"; +import { stderrLog } from "../../src/cli-log.js"; import { run } from "../../src/cli-run.js"; // A stream whose written text is kept in `text`; writes finish at once, so @@ -23,7 +24,13 @@ const runToExit = async ( const stdout = collector(); const stderr = collector(); const code = await new Promise<number>((resolve) => { - void run(command, stdout.stream, stderr.stream, resolve); + void run( + command, + stdout.stream, + stderr.stream, + stderrLog(stderr.stream, "info"), + resolve, + ); }); return { code, stdout: stdout.text(), stderr: stderr.text() }; }; diff --git a/test/retry/retry.test.ts b/test/retry/retry.test.ts index b943985..e0c4348 100644 --- a/test/retry/retry.test.ts +++ b/test/retry/retry.test.ts @@ -555,6 +555,36 @@ describe("withRetry", () => { expect(delays).toHaveLength(2); }); + it("tells `onRetry` of each attempt it retries, before the wait", async () => { + // A caller that logs retries, as the CLI does with `-v`, learns why + // each attempt failed and how long the next one is put off. The last + // attempt is not retried, so it is not reported. + const { sleep, delays } = recordingSleep(); + const retries: [string, number, number][] = []; + let calls = 0; + const failure = withRetry( + () => { + calls++; + return Promise.reject(new ApiError(`attempt ${calls}`, 503)); + }, + { + attempts: 3, + sleep, + random: () => 0.5, + onRetry: (err, attempt, delayMs) => { + retries.push([(err as Error).message, attempt, delayMs]); + }, + }, + ); + + await expect(failure).rejects.toThrow("attempt 3"); + expect(retries).toEqual([ + ["attempt 1", 1, 250], + ["attempt 2", 2, 500], + ]); + expect(delays).toEqual([250, 500]); + }); + it("does not retry at all when attempts is 1", async () => { const { sleep, delays } = recordingSleep(); let calls = 0; @@ -708,6 +738,7 @@ describe("retry defaults", () => { expect(resolved.maxDelayMs).toBe(DEFAULT_RETRY_OPTIONS.maxDelayMs); expect(typeof resolved.sleep).toBe("function"); expect(typeof resolved.random).toBe("function"); + expect(typeof resolved.onRetry).toBe("function"); }); it("resolves to the defaults when given nothing", () => { diff --git a/test/thumbnails/thumbnails.test.ts b/test/thumbnails/thumbnails.test.ts index 4acd89b..bf2be5e 100644 --- a/test/thumbnails/thumbnails.test.ts +++ b/test/thumbnails/thumbnails.test.ts @@ -544,11 +544,22 @@ describe("listMissingThumbnails", () => { const client = await login(counted.fetch, { ...noWait }); const lib = await openLib(client); - const missing = await listMissingThumbnails(lib, client); + const errors: string[] = []; + const missing = await listMissingThumbnails( + lib, + client, + (msg, level) => { + if (level === "error") errors.push(msg); + }, + ); lib.close(); - // Only the genuinely empty thumbnail is reported. + // Only the genuinely empty thumbnail is reported. The file that + // could not be checked is reported as an error. expect(missing.map((m) => m.fileID)).toEqual([101]); + expect(errors).toEqual([ + expect.stringMatching(/^\[.*\] Could not check file-102\.jpg: /), + ]); // And the 500 was retried rather than accepted as an answer: four // attempts is the library default. expect(counted.matched()).toBe(4); @@ -699,7 +710,15 @@ describe("fixMissingThumbnails", () => { const client = await login(buildThumbFetch(fixMock)); const lib = await openLib(client); - const results = await fixMissingThumbnails(lib, client, [101, 102]); + const errors: string[] = []; + const results = await fixMissingThumbnails( + lib, + client, + [101, 102], + (msg, level) => { + if (level === "error") errors.push(msg); + }, + ); lib.close(); expect(results.length).toBe(2); @@ -707,6 +726,10 @@ describe("fixMissingThumbnails", () => { const failure = results.find((r) => r.fileID === 102)!; expect(success.status).toBe("fixed"); expect(failure.status).toBe("failed"); + // The failed file, and only it, is reported as an error. + expect(errors).toEqual([ + expect.stringMatching(/^\[.*\] FAILED file-102\.jpg: /), + ]); }); it("skips a non-JPEG image and a video as unsupported, not failed (issue #17)", async () => {