53 Commits
Author SHA1 Message Date
sneak c4d4179938 Fix two intermittently failing library tests (closes #90)
check / check (push) Successful in 28s
Library.close() now returns a promise that resolves once the work it
started has finished: an in-flight refresh with its cache write, the ML
data fetch, and running precache sweeps. The interval test could see a
refresh's new state, close, and remove the directory while the write was
still running. Every library test and CLI command now awaits close(), and
tests hold each of the three writes open to prove close() waits for it.

The precache test waited for its stub source to be called, but the cache
records a file only after checking it on disk, so status() could lag. It
now waits for both fills to report "done".

Model: opus-5-5
2026-09-23 02:29:50 +00:00
clawbot c75c4f987c Pin three untested guards: download timer, URL fragment, short APP1 (closes #89)
check / check (push) Successful in 24s
The download idle deadline's timer is unref'd so it never holds the
process open, and a test checks no timer is left after a download
completes or fails. A test covers the rejection of "#" in a request
path. The EXIF scan accepts an APP1 segment only when its length is at
least 8, since a shorter one cannot hold the six-byte Exif header;
tests cover lengths 7 and 8.

Model: opus-5-5
2026-09-23 03:44:45 +02:00
clawbot 28a2beeab8 Make the download deadline an idle deadline and cancel failed bodies (closes #24)
check / check (push) Successful in 17s
downloadTimeoutMs now aborts a file or thumbnail download only when no
bytes have arrived for that long (default 60 s, was a 600 s cap on the
whole transfer), so a slow download that keeps making progress completes.
The abort reason is still a TimeoutError, so retry classification is
unchanged. A download that fails before reading the whole response body
cancels it, including when the temp file cannot be opened or the header
is malformed, so a failed file no longer holds its connection.

Model: opus-5-5
2026-09-23 03:14:42 +02:00
clawbot 2b410c3ed6 Build every ApiClient request URL in one place (closes #18)
check / check (push) Successful in 25s
getJSON built its URL with new URL and then overwrote the host and
path, which dropped a base path in a self-hosted apiOrigin; postJSON,
putJSON and the file and thumbnail download URLs joined strings. All of
them now go through one function next to ApiClient that accepts a path
with or without a leading slash and an origin with or without a
trailing slash or base path, and percent-encodes query parameters. A
path containing "?" or "#" is rejected.

Model: opus-5-5
2026-09-23 02:57:42 +02:00
clawbot d07692897b Move CLI commands into testable functions and test them (closes #12)
check / check (push) Successful in 27s
The command bodies in bin/quak.ts become functions in
src/cli-commands.ts that take their options and a context (output
streams, session directory, cache directory, session loader) and return
an exit code. bin/quak.ts wires them to commander and exits with that
code once stdout and stderr have drained; nothing below it calls
process.exit. test/cli/commands.test.ts drives the commands with a fake
client and temp directories. Output is unchanged.

Model: opus-5-5
2026-09-23 02:47:47 +02:00
clawbot ed535be1da Harden the backup tree's atomic copy (closes #22)
check / check (push) Successful in 27s
The backup copy now fsyncs its temp file before the rename and the
directory after it, using the download writer's new fsyncPath helper.
Each backup run deletes .quak-backup-*.tmp files whose process is no
longer running, leaving those of a concurrent backup alone. The rename
sites and the README backup layout state that a symlink at the
destination is replaced and the new file takes the temp file's
permissions, and the README names the temp files. Adds tests for a
missing and an unwritable destination directory for downloadFile and
downloadThumbnail.

Model: opus-5-5
2026-09-23 02:44:46 +02:00
clawbot d545dcd8b1 Harden the retry classifier and pin per-attempt deadlines (closes #80)
check / check (push) Successful in 42s
A POST or PUT is replayed only when every errno in the cause chain is a
connect errno and the walk reached the end of the chain, and
postJSON/putJSON no longer follow redirects, so a redirect is an
ApiError that is not retried. getRetryOptions() returns a copy. New
tests pin every errno the classifier names, the cause-chain depth
limit, a chain deeper than the limit, a two-error cycle, and a fresh
deadline per attempt for every retrying entry point. The README
endpoint list is now the one place naming the requests the replay rule
covers; code comments point to it.

Model: opus-5-5
2026-09-23 02:38:01 +02:00
clawbot 52f58f5d2b Harden the JPEG EXIF scan against malformed input (closes #11)
check / check (push) Successful in 29s
The segment scan behind `backup-metadata --exif` now checks every
segment length against the bytes that remain and stops on lengths
under 2, so a truncated or corrupt original can neither throw nor loop.
A malformed or unparseable EXIF segment is recorded as
`imageMetadata.exifError`, and a failure to read the original as
`imageMetadataError` in the per-file JSON, instead of the field being
silently left out. Tests use short hand-built byte arrays.

Model: opus-5-5
2026-09-23 02:28:15 +02:00
clawbot b44c4ba6d7 Keep make test from collecting tests in nested checkouts (closes #25)
check / check (push) Successful in 33s
vitest does not read .gitignore when finding tests, so a checkout nested
under .claude/ had its whole test/ tree run as part of this suite.
vitest.config.ts adds .claude/** to vitest's default excludes. The new
packaging test plants a nested checkout in a temp directory and fails if
vitest, run with this config, would collect it.

Model: opus-5-5
2026-09-23 02:18:31 +02:00
clawbot d50b296d3a Drop the deprecated @types/libsodium-wrappers-sumo stub (closes #27)
check / check (push) Successful in 46s
The package is an empty stub with no declarations; libsodium-wrappers-sumo
ships its own types. Removed with yarn remove, which regenerated yarn.lock.

Model: opus-5-5
2026-09-23 02:15:08 +02:00
clawbot b7d6ab99f4 Validate session snapshots and wipe keys on logout (closes #10)
check / check (push) Successful in 41s
Client.fromJSON checks every snapshot field and each key's decoded length
and throws an error naming the bad field. toJSON reads the token through a
new ApiClient.getAuthToken and throws when there is none. logout zeroes the
key buffers in place; collectionsSince re-checks for logout after its
request so it never decrypts with zeroed keys. The CLI now reports a
corrupt session file separately from a missing one.

Model: opus-5-5
2026-09-23 02:08:02 +02:00
clawbot 3871d6228e Sanitize file names taken from server metadata (closes #9)
check / check (push) Successful in 37s
A file title or album name decrypted from server data could name a path
outside the chosen directory (`../../.ssh/authorized_keys`). One module,
src/filename.ts, now makes such names safe for `quak get`/`get-thumb`
without `--out`, downloadFile/downloadThumbnail without outPath, and the
backup and metadata backup trees. Originals-cache extensions are limited to
letters and digits. A user-supplied path is still used as is. decryptFile
reads a missing or non-string title as "" and rejects metadata that is not
a JSON object.

Model: opus-5-5
2026-09-23 02:04:31 +02:00
clawbot fe952d3e62 Rewrite the README API reference for the new library surface (closes #53)
check / check (push) Successful in 28s
Rewrites the README API reference to match the shipped library surface: Library.open options (XDG cache dir + userID, downloadDirectory, refreshIntervalSeconds, precache and cache-size options), default vs fresh reads (Library.fresh()), snapshot/subscribe, albums/photos/timeline, Photo.original/thumbnail, thumbnails.ensure, mldata search, backup, the request pools and on-disk layout, and the CLI (--cache-dir, fresh reads). Every documented signature verified against the code. Notes the deferred content-hash integrity check (#68). Also issue #13.

Model: opus-4-8
2026-09-23 00:27:30 +02:00
clawbot d23d3f8f47 Port the quak CLI to the library API (closes #52)
check / check (push) Successful in 17s
Ports the CLI to the library API. collections/files/get/get-thumb use the fresh read variants (Library.fresh(), current server state); backup runs lib.backup; backup-metadata and the missing-thumbnail helpers enumerate via the library. files/get output is byte-identical to the pre-port CLI — raw metadata.title, microsecond creationTime, pre-port row order — exit codes unchanged. Adds --cache-dir; fixes the helper JPEG-only assumption (closes #17).

Model: opus-4-8
2026-09-23 00:00:55 +02:00
clawbot aeccb489b5 Fresh read variants that await a server round-trip (closes #75)
check / check (push) Successful in 28s
Adds Library.fresh(): forces a refresh, awaits its completion and persist, then returns the albums/photos/timeline read namespaces now reflecting a completed server round trip — the caller awaits and is guaranteed current at resolve. Default reads and the background loop are unchanged (immediate-from-cache). Concurrent fresh reads coalesce to one in-flight refresh; a fresh read whose refresh fails rejects rather than answering stale. The CLI adopts fresh reads separately (#52).

Model: opus-4-8
2026-09-22 23:29:15 +02:00
clawbot 2e00139d3c Precache all thumbnails and pinned originals inside open() (closes #48)
check / check (push) Successful in 26s
Precaches aggressively inside open(): every thumbnail (newest first, through the shared thumbnail pool, never evicted; visible/ahead requests preempt the background fill) and the pinned originals — the favorites album plus every file within precacheOriginalsDays (default 7) of the newest takenAt — through the content pool. The pinned set integrates with the #47 eviction hook; when the window moves or a favorite is removed, files become ordinary evictable originals. open() options precacheThumbnails/precacheOriginals/precacheOriginalsDays; progress via onProgress/status().

Model: opus-4-8
2026-09-22 21:21:34 +02:00
clawbot e8575780e4 Content-similarity search surface over the CLIP index (closes #50)
check / check (push) Successful in 15s
Adds lib.mldata search over the CLIP index (#49): forFile returns a file's stored payload; similar ranks nearest files by cosine on the CLIP embedding; searchByEmbedding ranks the index against a caller-supplied query vector. All RAM-only, reusing the packed Float32Array index and id list. No text encoder is bundled — the caller provides the query embedding. (Redo of the reverted first attempt, now tsc-clean.)

Model: opus-4-8
2026-09-22 20:45:59 +02:00
clawbot 17d1d74615 Rewrite the backup command on the library API with a durable failure ledger (closes #51)
check / check (push) Successful in 25s
Rewrites backup on the library API. backup() refreshes, fetches pending originals (and optionally thumbnails) through the pools reusing the content cache, then materialises the unchanged collections/ symlink views + per-collection JSON + sidecars from the model. A symlink failure no longer aborts the run (closes #8). failures.json reconciles against each run's attempted set — since-deleted/out-of-scope/resolved entries clear, still-failing retained, one attempt per file per run — and the exit-code contract is preserved.

Model: opus-4-8
2026-09-22 20:21:49 +02:00
clawbot c05d63a2f0 Originals cache size limit with least-recently-used eviction (closes #47)
check / check (push) Successful in 25s
Bounds the originals cache and evicts least-recently-used. cacheOriginalsMaxBytes default 100 GiB; the effective limit adapts down via fs.statfs to keep freeBelowBytes (default 50 GiB) free. Over-limit writes evict unpinned originals oldest-mtime-first (mtime touched on read); pinned files (favorites + latest week) are skipped and an over-budget on-demand fetch proceeds over-limit. Every in-flight write is excluded from eviction, so concurrent fetches never delete each other's just-stored file. Only cacheDirectory/originals is evicted; downloadDirectory and thumbnails never.

Model: opus-4-8
2026-09-22 20:13:22 +02:00
clawbot 5db59a6e2b On-disk content and thumbnail cache with per-photo fetch and prefetch (closes #46)
check / check (push) Successful in 33s
Adds the on-disk content and thumbnail cache keyed by fileID: originals/ and thumbnails/ under cacheDirectory, present-means-complete (streaming atomic rename), orphan temp reaping on open. Photo.original/thumbnail return a cached path with no network when present, else fetch through the shared request pool; thumbnails.ensure drives the thumbnail pool with priority, dedup and AbortSignal. One shared RequestPools set serves both the ML fetch and the content cache. Content-hash integrity is deferred (#68); authenticated streaming decrypt guarantees integrity now.

Model: opus-4-8
2026-09-22 18:38:22 +02:00
clawbot 61dfec8d38 Revert ML search (#50) to restore a green next (build fix pending)
check / check (push) Successful in 18s
Reverts the #50 merge (224bd101): mlsearch.ts failed tsc in the CI build (make check does not run make build, so it slipped past review). next restored to green; #50 to be redone with make build in its gate.

Model: opus-4-8
2026-09-22 18:21:54 +02:00
clawbot 224bd101ab Content-similarity search surface over the CLIP index (closes #50)
check / check (push) Failing after 23s
Adds lib.mldata search over the CLIP index (#49): forFile returns a file's stored payload; similar ranks nearest files by cosine on the CLIP embedding; searchByEmbedding ranks the index against a caller-supplied query vector. All RAM-only, reusing the packed Float32Array index and id list. No text encoder is bundled — the caller provides the query embedding.

Model: opus-4-8
2026-09-22 18:14:13 +02:00
clawbot d7f415fe29 Fetch, store, and index per-file ML data (closes #49)
check / check (push) Successful in 14s
Adds the machine-learning (magic) data layer: fetches per-file ML payloads (face detections + CLIP embeddings) via the existing metadata-backup fetch through the metadata pool after each refresh, decrypts and gunzips them, and stores one mldata/<fileID>.json per file by rename (present-means-complete). A derived index (mldata/clip.f32 + clip.json) loads in one read and is rebuilt whenever it disagrees with the payloads on disk in either direction, so an interrupted backfill self-heals. Never in metadata.json; incremental on later refreshes; progress via onProgress/status.

Model: opus-4-8
2026-09-22 17:54:40 +02:00
clawbot c5c1f387df In-process read surface: albums, photos, and timeline grouping (closes #44)
check / check (push) Successful in 25s
Adds the in-process read surface, served from RAM with args-object signatures: albums (list/byName/byID), photos (byID/records -> plain PhotoRecord[]), thin Album/Photo wrappers, and timeline.groups (day/week/month) with a PhotoFilter (albumID/text/fileTypes/hasLocation/includeArchived; hidden excluded). Week keys use ISO YYYY-Www; each file appears once per group, newest first. Built on the #43 snapshot projection; no network.

Model: opus-4-8
2026-09-22 17:21:48 +02:00
clawbot 000d395c87 Three bounded request pools for metadata, content, and thumbnails (closes #45)
check / check (push) Successful in 55s
Adds three independent bounded request pools — metadata (10 in flight), content (5), thumbnails (25), each overridable — with on-demand-before-background priority and in-flight dedup (a shared key runs once); an idle pool never lends slots, and retries run inside a slot. Self-contained module; the content cache wires it into the library later.

Model: opus-4-8
2026-09-22 15:31:21 +02:00
clawbot fbb8ae44a7 Plain-record library snapshot and change subscription (closes #43)
check / check (push) Successful in 30s
Adds a synchronous, key-free snapshot() returning LibrarySnapshot (one PhotoRecord per fileID, deduped, newest first) with edited-name/time precedence (pubMagicMetadata over basic metadata) in milliseconds, plus AlbumRecord (favorites identified by type). subscribe({onChange}) delivers LibraryChange (changed/removed albums and files, refreshedAt) only when a refresh changes something; unsubscribe stops delivery. Built on the existing refresh loop; served from RAM, safe to send over IPC.

Model: opus-4-8
2026-09-22 15:22:52 +02:00
clawbot 57e0c69651 Accumulate streamDecrypt reads linearly, not quadratically (closes #21)
check / check (push) Successful in 14s
streamDecrypt no longer recopies the whole accumulation buffer on every network read. Reads are queued with a running byte count and a contiguous buffer is materialised only at each ENC_CHUNK_SIZE boundary, with a straddling read split via a subarray view, so each byte is copied once instead of O(n^2). TAG_FINAL truncation detection, final-chunk handling, retry, and the per-chunk progress hook are unchanged. A new test feeds a multi-chunk body through a ReadableStream that yields many small pieces, exercising the fragmented-read path.

Model: opus-4-8
2026-09-22 15:13:09 +02:00
clawbot 7570055a5b Library.open with a transparent background refresh (closes #42)
check / check (push) Successful in 24s
Library.open loads metadata.json and serves reads from RAM: an empty cache awaits the first refresh, an existing cache returns at once and refreshes in the background so an unreachable server never stalls open(). A background timer refreshes every refreshIntervalSeconds (default 3), diffing only changed albums via the resumable cursor+tombstone enumerators and rewriting metadata.json only when something changed. A refresh failure is invisible to reads and surfaced via status()/onProgress; a failed save keeps status().lastError set and retries until one lands, so a stale disk is never masked. No sync()/refresh()/serverReachable surface; status() and close() included.

Model: opus-4-8
2026-09-22 14:52:02 +02:00
clawbot 4b4f550f89 Stream decrypted downloads to disk with bounded memory (closes #40)
check / check (push) Successful in 32s
Originals no longer buffer the whole decrypted file in RAM: streamDecrypt hands each secretstream chunk to a sink and the download path writes it to the staged temp file, so peak memory is one chunk regardless of file size. The atomic write moved inside the retry loop; only a TAG_FINAL-authenticated attempt renames; truncation leaves no destination file. Does not close #21 (the streamDecrypt accumulation-buffer recopy stays open).

Model: opus-4-8
2026-09-22 13:54:20 +02:00
clawbot 72ea8dcb01 On-disk JSON metadata store for the local cache (closes #41)
check / check (push) Successful in 23s
Adds the metadata.json store: loads whole into RAM with id-lookup Maps, rewrites whole through the exported fsync atomic writer (temp, fsync, rename, dir fsync); a missing, unparseable, or wrong-schema file loads as empty (it is a cache); directory 0700, file 0600. No lock file, no public sync().

Model: opus-4-8
2026-09-22 12:52:50 +02:00
clawbot 42a6c17d49 Resumable, deletion-aware collection and file enumeration (closes #38)
check / check (push) Successful in 24s
Adds collectionsSince/filesSince taking a starting cursor, returning the resumable max-updationTime cursor and a separate list of tombstoned ids; filesSince throws instead of looping when the server reports hasMore without advancing (closes #7). listCollections/listFiles stay as thin, unaffected wrappers.

Model: opus-4-8
2026-09-22 12:37:09 +02:00
clawbot 8f575550af Durable atomic writer with fsync and per-chunk download progress (closes #39)
check / check (push) Successful in 41s
The atomic writer fsyncs the staged temp file before rename and the directory after, and is exported for reuse. downloadFile/downloadThumbnail gain an optional per-chunk onProgress hook (non-decreasing, final equals bytesWritten; no-op when absent). Retry and TAG_FINAL checks unchanged.

Model: opus-4-8
2026-09-22 12:01:06 +02:00
clawbot ead083c1d6 Carry file size, thumbnail size, and deletion flag through decryptFile (closes #37)
check / check (push) Successful in 25s
decryptFile now sets file.size/thumbnail.size from raw.info (undefined when absent) and carries an optional isDeleted on EnteFile. Plain EnteFile return, no caller changes; listFiles keeps filtering tombstones. Tests cover the three fields and the size-absent case.

Model: opus-4-8
2026-09-22 12:01:03 +02:00
clawbot d1d6cdd4f0 Revert the 16 commits pushed to next on 2026-09-04 by an agent outside the managed fleet
check / check (push) Successful in 1m40s
sneak, 2026-09-05: "inference instance stopped. undo its rogue work." The
reverted commits stay in history; nothing else on next is touched.

Model: fable-5-1
2026-09-05 09:33:23 +00:00
user 48db9b438a test(lint-once): read docker image/builder build as builds too
check / check (push) Successful in 1m12s
`docker image build` and `docker builder build` are management-command
spellings of the same build, take the same -f, and were emitting no edge at
all — the same shape as the buildx miss.
2026-09-04 12:08:54 +00:00
user 81150f433c test(lint-once): disclose the build shapes read, correct the run: | claim
check / check (push) Successful in 13s
Header now states which docker invocation shapes are recognised as builds
(bare, buildx, and either through global flags) and which are not (compose),
and that the RUN keyword is read case-insensitively with any whitespace
separator. The workflow comment claimed a `run: |` block fails the count;
it does not — the count is unmoved by it and the pinned resolved list is
what turns it red.
2026-09-04 12:06:44 +00:00
user f67a1c4d92 test(lint-once): match RUN case-insensitively, follow buildx builds
check / check (push) Successful in 1m8s
WIP round 9: items 1-3. Verification matrix still to re-run.
2026-09-04 12:04:24 +00:00
user ffc817522e test(lint-once): pin the two-build and later-flag shapes
check / check (push) Successful in 15s
The false green: script/lint chaining a second docker build after the
lint image ran prettier twice and counted once. The misresolution: a
bare docker build followed by cp -f resolved to the cp's file. Pin
both, and each separator that bounds an invocation.
2026-09-04 11:51:17 +00:00
user 88a5fcaa87 lint-once: bound the docker file flag to its own invocation
check / check (push) Failing after 42s
edgesOf used a single test/exec for docker build, so a line with two
builds produced one edge and the file flag was searched across the whole
line. Follow every occurrence with matchAll and slice each one to the
next shell separator before looking for its flag.

Also correct the README's claim that script/fmt-check is the one
formatting path left on the host.
2026-09-04 11:49:38 +00:00
user 2fcb3e6ced test(lint-once): follow a short file flag with its value attached
check / check (push) Successful in 14s
`docker build -fDockerfile.lint .` is a plain `-f` naming a literal file —
the flag parser reads the attached value for any shorthand — but the
resolver's `[=\s]+` required a separator, so the invocation fell through to
the default `Dockerfile` edge. Mutating the `check` recipe to that spelling
left the suite 52/52 green while `-f Dockerfile.lint` was caught.

The header already promised this form was followed: it claimed the file
named by `-f` is resolved wherever the flag sits, and disclosed only a
bundled short cluster (`-qf <file>`) as unfollowed. Overclaiming is the
defect, so the resolver is taught the form rather than the claim narrowed.

The attached form is allowed only for the short flag, keeping `--force-rm`
out of it; `--file` still requires `=` or whitespace. The `-qf` cluster
limitation is untouched and still pinned.
2026-09-04 11:37:08 +00:00
user 197296edba lint-once: follow every spelling of docker build's file flag
check / check (push) Successful in 1m17s
The edge resolver matched `-f <file>` only. `docker build --file=X` fell
through to the default `Dockerfile` edge, so a second prettier pass wired in
that way was followed into the wrong file, counted nothing, and left the suite
green -- the exact false green this test exists to prevent, reachable by
writing the flag the long way.

Mutation, adding one line to the `check` recipe, before this commit:

    @docker build -f Dockerfile.lint .        1 failed / 47  caught
    @docker build --file=Dockerfile.lint .    48 passed / 48  MISSED

After, all four spellings fail with `expected 2 to be 1`:
`-f X`, `-f=X`, `--file X`, `--file=X`.

Docker takes the value either way for both the short and long flag, so the
resolver now reads `(?:-f|--file)[=\s]+`, still searched anywhere in the
invocation rather than at a fixed position. A leading \s keeps a longer flag
ending in the same letters (`--force-rm`) from supplying the match.

The header claimed `docker build -f <file>` coverage without qualification,
which a reader could take to include the long form it did not follow; it now
names all four forms and the fallback. The one limitation it asserts -- a
bundled cluster like `-qf X` resolving to the default -- is pinned by a test,
since an unpinned limitation is how the header drifts back into overclaiming.
2026-09-04 11:31:06 +00:00
user bd88eced84 README: state the container-lint claim as what is actually true
check / check (push) Successful in 1m14s
The Linting section this PR adds claimed "There is no host lint path" and
then, two paragraphs later, documented script/fmt-check as a host-side
formatting check. Both cannot be true, and the absolute one is the false one.

What is true is narrower: no lint path reachable from script/check or
script/precommit runs on the host, so every lint verdict those two produce
comes from the container. script/fmt-check stays as a standalone entrypoint,
now stated as the one host formatting path with nothing reaching it. The
"exactly one place" and "in the container only" phrasings elsewhere are
qualified the same way.
2026-09-04 11:27:49 +00:00
user 956870889f Update the make variable header to match the ?= and prefix rules
check / check (push) Successful in 15s
2026-09-04 11:19:45 +00:00
user 18b0f039b5 Pin the export/override prefix and ?= first-wins with tests
check / check (push) Failing after 7s
2026-09-04 11:18:19 +00:00
user 18226d345b WIP: fix export/override prefix and ?= first-wins in make variable parser
check / check (push) Successful in 14s
Two defects in the lint-once resolver's make variable handling:

- The assignment pattern anchored at the start of the name, so
  `export FMT := script/fmt-check` was never collected and `@$(FMT)`
  went unresolved. An optional `export `/`override ` prefix is now
  allowed.
- `?=` assigns only when the name is unset, so the first assignment
  wins. The parser called .set() unconditionally, letting a later
  `FMT ?= script/build` overwrite an earlier `FMT := script/fmt-check`
  and resolve to a command make never runs.

Tests pinning both to follow.
2026-09-04 11:17:32 +00:00
clawbot 075b1bb921 Expand single-line make variables in the lint-once resolver
check / check (push) Successful in 1m13s
A recipe of `@$(FMT)` with `FMT := script/fmt-check` gave `make check` two
prettier passes while the suite stayed green: the resolver read the line as
invoking nothing. The shipped Makefile already writes recipes that way
(`@$(YARN) tsc --watch`), so this was a gap in the repo's own house style.

Variables assigned a literal on one line (`:=`, `=`, `?=`) are collected in a
pass of their own and substituted into target and recipe lines. Values needing
evaluation -- another reference, a make function, a `define` body -- are left
verbatim, and the header's not-followed list now says so.
2026-09-04 11:05:41 +00:00
clawbot ff4cc63c8b Name the define/endef hole in the lint-once header
check / check (push) Successful in 1m8s
A `define EXTRA ... endef` body pulled into a recipe as $(EXTRA) gives
`make check` a second prettier pass that the walk still scores as one.
The parser expands no variables, so this is a name it cannot resolve,
not a body it declines to read. The header's exclusion list says so
rather than claiming coverage the code does not have.
2026-09-04 10:55:41 +00:00
clawbot f4ecef8820 Follow make conditionals and semicolon recipes in lint-once
check / check (push) Successful in 15s
makeRecipes() reset the current target on every non-tab line, so an
ifeq/endif block ended the recipe and every tab-indented line inside it
was discarded; and the target line's tail was read entirely as
prerequisites, so `check: ; @script/fmt-check` split to tokens that
named no target and vanished. Both gave `make check` a second prettier
pass with the suite green.

Conditional directives no longer end a recipe, and every branch is
treated as reachable rather than evaluating the condition. The target
line is split on the first `;`: what precedes it is the prerequisite
list, what follows is the first recipe line. Both pinned directly, and
the header's exclusion list now names what the parser actually skips.
2026-09-04 10:54:24 +00:00
clawbot 9ed92e1231 Format the lint-once resolver changes
check / check (push) Successful in 34s
Reflow the heredoc RUN fixture array in the BuildKit heredoc test to
satisfy prettier --check, which rejected the single-line form. No
behavioural change to the tests or the resolver.
2026-09-04 10:31:40 +00:00
clawbot 8bf5138582 WIP: follow Makefile prerequisites and $(MAKE) in lint-once
check / check (push) Failing after 37s
2026-09-04 10:05:38 +00:00
sneak 2bfa11c10c Walk the path CI runs in lint-once, and pin the branch the container installs (closes #33)
check / check (push) Successful in 35s
The header of test/packaging/lint-once.test.ts claimed a duplicate prettier
pass is caught wherever it is added. It was not: the walk started at
`make check`, which never reads `Dockerfile`, so appending
`RUN yarn run prettier --check .` to the image that `script/cibuild` builds
left the suite green — two prettier passes on the one path where it matters
most. The walk now also starts at `.gitea/workflows/check.yml` and follows
its `run:` steps into `script/cibuild` and from there into both images, so
the graph under test is the one CI executes rather than the one it was
assumed to execute. Reaching `script/cibuild` and `Dockerfile` is asserted,
and the test and build image is asserted to invoke prettier zero times.

The lockfile assertion was a substring check against the whole of
`script/bootstrap`. That script has two install sites, and the containers
take the second, because the pinned node image ships yarn; changing that
site to a bare `yarn install` kept the suite green while the container's
install stopped being pinned. `install_js_deps` is now resolved out of the
script and split at its `missing yarn` guard, and every `yarn install`
occurrence in each branch is required to carry `--frozen-lockfile`. That the
container runs `script/bootstrap` at all is asserted too, so the lockfile
assertions cannot end up describing a script the image never executes.

Prettier is counted per occurrence instead of per line:
`prettier --check . && prettier --check src` was one invocation by the old
count. The `continue` that followed a counted line also dropped every
script, make, yarn and docker edge sharing that line, so a subtree could be
hidden behind a single `&&`; edges are now extracted from every line.

Undercounting is what would make this file worthless, so every way of
reaching nothing is a thrown error rather than a quiet zero: an unknown
Makefile target, an unknown package.json script, a missing script file, a
node that resolves to no commands, and an unknown node kind. All five are
tested, as is a walk that legitimately counts zero, and the cycle guard.

Every assertion in the file was mutation-tested: changed to assert something
else, run, and confirmed to fail for its own named reason. The two mutations
above were reproduced and both now turn the suite red.

test/packaging/entrypoints.test.ts said `make check` runs test, lint and
fmt-check. Formatting has been part of the lint container since the
duplicate host pass was removed, so the comment now says what it does.
2026-08-10 13:41:12 +00:00
sneak a73f0abbe8 Check formatting once per make check, in the container (closes #29)
check / check (push) Successful in 59s
script/check ran script/test, script/lint and script/fmt-check. Since
linting moved into Docker, script/lint is a build of Dockerfile.lint,
which runs `prettier --check .` as a build step — so make check checked
formatting twice over the same tree: once in the container and once on
the host. script/precommit had the same pair.

Drop the script/fmt-check call from both. The container keeps the check,
because a successful Dockerfile.lint build is what CI treats as proof of
a clean tree, and it is the stronger of the two verdicts: its prettier is
digest-pinned and installed under --frozen-lockfile, while the host's is
whatever the working tree happens to have. The pre-commit hook is
unchanged in what it catches — script/lint still fails a badly formatted
tree, and therefore the commit.

script/fmt-check survives as a standalone entrypoint, as REPO_POLICIES.md
requires, for asking the formatting question by itself without docker.
Its verdict cannot drift from the container's: prettier is pinned to an
exact version, installed from yarn.lock in both places, and reads
.gitignore as its default ignore file, which is why .dockerignore keeps
.gitignore in the build context.

The count is asserted rather than promised. test/packaging/lint-once.test.ts
walks the invocation graph from each entrypoint — through the Makefile
shims, the script/ calls, the package.json scripts and the docker build
into Dockerfile.lint's RUN steps — and counts prettier invocations: one
per make check, one per script/precommit, and one each for make lint and
make fmt-check alone, so neither can become a no-op that satisfies the
count trivially. The walk also asserts which nodes it reached, so a
restructure that defeats the resolver fails the test instead of quietly
counting zero.

Observed: 2 prettier invocations per make check before, 1 after.
2026-08-10 13:05:03 +00:00
sneak fed39d19cf Run all linting in Docker via Dockerfile.lint (closes #30)
check / check (push) Successful in 1m2s
Linting now happens in one place only: a new root Dockerfile.lint copies
the repo into the digest-pinned node image already used by Dockerfile and
runs eslint and prettier as build steps, so a successful build is a clean
lint. script/lint is reduced to building it, which also works where the
docker daemon is remote and bind mounts are impossible. No host lint path
survives: the "lint" script is gone from package.json, so there is no
second, unpinned way to get a lint verdict.

Caching is waived for lint, because a lint build over an unchanged tree
returns success in well under a second having linted nothing. LINT_EPOCH
is the cache buster and it fails closed exactly as CHECK_EPOCH does: an
unset ARG is the empty string, which is a perfectly stable cache key, so
the guard rejects it and a bare `docker build -f Dockerfile.lint .` errors
out instead of serving a green it did not earn. Both linters sit below the
guard, so a fresh epoch forces them to execute while the bootstrap and
dependency layers above stay cached.

That makes script/lint a docker build, which nothing inside a container
may call. script/check calls script/lint, so the Dockerfile image can no
longer run make check: the lint stage and its COPY --from=lint ordering
hack are deleted, and the remaining stage runs make test and make build
under the existing CHECK_EPOCH guard. script/cibuild is now the composite
gate and builds the lint image first, so a lint failure is reported before
the slower suite runs.

The .dockerignore exclusions are unchanged and still apply to the lint
build, including the .claude/ exclusion (eslint's flat config does not
ignore dot-directories, so a nested worktree in the context would be
linted) and the deliberate exception that keeps .gitignore in the context
for prettier. A new test asserts no per-Dockerfile ignore file shadows the
root one for either image, and test/packaging/lint-docker.test.ts asserts
the whole shape: the docker-only lint path, the digest pin, manifests
copied before sources, the fail-closed guard with both linters below it,
the absence of a lint stage or make check in Dockerfile, and the build
order in script/cibuild.
2026-08-10 12:39:23 +00:00
70 changed files with 14935 additions and 1568 deletions
+9 -18
View File
@@ -1,36 +1,27 @@
# Lint stage — fast feedback on formatting and lint issues
# node 22.22.0 on Alpine 3.23.3 (node:22-alpine), 2026-08-09
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS lint
WORKDIR /app
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
RUN make fmt-check
RUN make lint
# Check stage — the full suite and the build
# Test and build image: the suite, then the compile.
#
# Linting deliberately does not happen here. `script/lint` is a build of
# Dockerfile.lint, and `script/check` calls `script/lint`, so running
# `make check` in this image would mean running `docker build` inside a
# container. Lint runs exactly once, in Dockerfile.lint; script/cibuild
# builds that first and this second.
# node 22.22.0 on Alpine 3.23.3 (node:22-alpine), 2026-08-09
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS check
WORKDIR /app
# Force BuildKit to run the lint stage before proceeding. Without this the
# two stages run in parallel and a lint failure can lose the race.
COPY --from=lint /app/yarn.lock /dev/null
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
# CHECK_EPOCH is a cache buster: without it Docker serves `make check` from
# CHECK_EPOCH is a cache buster: without it Docker serves the test layer from
# cache on an unchanged tree, the suite never executes, and the build still
# exits 0. The guard makes an absent argument a hard failure — an unset ARG
# is the empty string, which is a perfectly stable cache key, so a plain
# `docker build .` would otherwise still get the false green. Fail closed.
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
RUN make check
RUN make test
ARG CHECK_EPOCH
RUN [ -n "$CHECK_EPOCH" ] || exit 1
+35
View File
@@ -0,0 +1,35 @@
# Lint image: every lint run happens here, and nowhere else. The repo is
# COPYed into a digest-pinned image and the linters run as build steps, so a
# successful build IS a clean lint. `script/lint` does nothing but build this
# file, which also works where the docker daemon is remote and bind mounts are
# impossible. Nothing that runs inside a container may call `script/lint`:
# that is why Dockerfile no longer runs `make check`.
# node 22.22.0 on Alpine 3.23.3 (node:22-alpine), 2026-08-09
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS lint
WORKDIR /app
# Manifests before sources, so the dependency install layer stays cached
# until package.json or yarn.lock changes. script/bootstrap ends in
# `yarn install --frozen-lockfile`; the lint steps below are deliberately
# not cached.
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
# LINT_EPOCH is a cache buster, with the same fail-closed contract as
# CHECK_EPOCH in Dockerfile. No lint cache is wanted: on an unchanged tree
# Docker serves the linter layers in well under a second, having linted
# nothing, and the build still exits 0. The guard makes an absent argument a
# hard failure — an unset ARG is the empty string, which is a perfectly
# stable cache key, so a plain `docker build -f Dockerfile.lint .` would
# otherwise get exactly that false green. Every layer below this one is a
# child of the guard, so a fresh epoch forces all of them to execute.
ARG LINT_EPOCH
RUN [ -n "$LINT_EPOCH" ] || exit 1
# The linters are invoked directly rather than through `make lint`, because
# `make lint` is the build of this file.
RUN yarn run eslint .
RUN yarn run prettier --check .
+343 -88
View File
@@ -38,34 +38,47 @@ yarn quak get 67890 --out ./photo.jpg
yarn quak backup ./my-backup
```
For library use:
For library use, the primary surface is the cache-backed `Library`:
```ts
import { Client } from "quak";
import { Client, Library } from "quak";
// Log in once; the client satisfies the library's client interface.
const client = await Client.login({
email: "you@example.com",
password: "your-password",
});
for (const c of await client.listCollections()) {
console.log(c.id, c.name);
const files = await client.listFiles(c.id, c.key);
for (const f of files) {
console.log(` ${f.metadata.title} [${f.metadata.fileType}]`);
// 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).
const lib = await Library.open({ client });
// Default reads answer synchronously from the local cache — no network.
for (const album of lib.albums.list()) {
console.log(album.collectionID, album.name);
for (const photo of album.photos.list()) {
console.log(` ${photo.title} [${photo.fileType}]`);
}
}
// Download a file
const files = await client.listFiles(collectionID, collectionKey);
await client.downloadFile(files[0], "./photo.jpg");
// Fresh reads await a server round-trip and answer with current state.
const { albums } = await lib.fresh();
console.log(`${albums.list().length} albums as of now`);
// Serialize session for later (consumer handles persistence)
const snapshot = client.toJSON();
// ... later:
const restored = Client.fromJSON(snapshot);
// Fetch (and cache) one photo's full-resolution bytes.
const photo = lib.photos.byID({ fileID: 12345 });
if (photo) {
const { path } = await photo.original();
console.log(`original at ${path}`);
}
await lib.close();
```
The lower-level `Client` (login, session serialization, and the raw
enumeration/download calls) is exported too and documented under Design below.
## Entrypoints
This repository adheres to the
@@ -86,31 +99,70 @@ alpine. We provide:
files the compiler wrote, and make the CLI executable (our own extension)
- `script/test` — run the test suite (vitest, hard-capped at 30s where `timeout`
is available, verbose rerun on failure)
- `script/lint` — run eslint and a prettier check
- `script/lint` — run eslint and a prettier check, by building
`Dockerfile.lint`; requires docker (see Linting below)
- `script/fmt` — format all files with prettier (writes)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
extension)
- `script/docker` — build the Docker image, tagged via `script/projectname`
- `script/cibuild` — cd to the repo root and run the image build (what CI runs;
the build runs `make fmt-check` and `make lint` in a first stage, then
`make check` and `make build` in a second)
- `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 (see Linting below)
- `script/check` — run all checks: `test`, `lint` (our own extension)
- `script/docker` — build the test and build image, tagged via
`script/projectname`
- `script/cibuild` — cd to the repo root and build both images (what CI runs):
`script/lint` first, then the `Dockerfile` image, which runs `make test` and
`make build`
- `script/precommit` — run by the git pre-commit hook (our own extension); runs
`script/lint` and `script/fmt-check` but deliberately not the tests, so the
TDD red-phase commit can land
`script/lint`, which checks both lint and formatting, 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
`make hooks` installs the pre-commit hook that runs `script/precommit`.
Both `script/docker` and `script/cibuild` pass
`--build-arg CHECK_EPOCH="$(date +%s)"`. The Dockerfile refuses to build without
it. This is deliberate: on an unchanged tree Docker would otherwise serve the
`make check` layer from cache, so the suite would never run and the build would
still exit 0. A changing epoch invalidates the check and build layers on every
invocation while leaving the dependency layers below them cached, and the
missing-argument guard means a bare `docker build .` fails loudly instead of
quietly reporting a green it did not earn.
### Linting
Linting runs in a container, one way, everywhere. `script/lint` builds
`Dockerfile.lint`, which copies the repo into a digest-pinned node image and
runs eslint and prettier as build steps, so a successful build is a clean lint.
There is no host lint path: docker is required to lint, and that also works
where the docker daemon is remote and bind mounts are impossible.
The formatting check is part of that, not a step beside it. `script/check` and
`script/precommit` therefore call `script/lint` and stop; neither calls
`script/fmt-check` as well, which would run prettier a second time over the same
tree for the same verdict — and the weaker of the two, since the host's prettier
is whatever the working tree has installed. So `make check` and the pre-commit
hook both still fail on a badly formatted tree, and prettier runs exactly once
in each. `test/packaging/lint-once.test.ts` asserts that count by walking the
invocation graph, so a second pass cannot creep back in unnoticed.
`script/fmt-check` remains as a standalone entrypoint for asking the formatting
question on its own, without docker and without the rest of lint. Its verdict
cannot drift from 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`
deliberately keeps `.gitignore` in the build context.
Lint happens in exactly one place, which constrains the rest of the build.
`script/check` calls `script/lint`, so `make check` cannot run inside a
container without asking for docker inside docker. The image built from
`Dockerfile` therefore runs `make test` and `make build` and does not lint;
`script/cibuild` builds `Dockerfile.lint` first and that image second, so CI
gets both verdicts.
### Build epochs
`script/lint` passes `--build-arg LINT_EPOCH="$(date +%s)"`, and `script/docker`
and `script/cibuild` pass `--build-arg CHECK_EPOCH="$(date +%s)"`. Both
Dockerfiles refuse to build without their argument. This is deliberate: on an
unchanged tree Docker would otherwise serve the linter and test layers from
cache, so nothing would run and the build would still exit 0 — a lint build over
an untouched tree returns success in well under a second, having linted nothing.
A changing epoch invalidates every layer below the guard on every invocation
while leaving the dependency layers above them cached, and the missing-argument
guard means a bare `docker build .` fails loudly instead of quietly reporting a
green it did not earn: an unset build argument is the empty string, which is a
perfectly stable cache key.
## Rationale
@@ -145,8 +197,10 @@ All work on quak is test-driven. No exceptions.
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. The Dockerfile runs `make check` and `make build`, so
neither a red branch nor one that does not compile can pass CI.
`main` is always green. CI runs `script/cibuild`, which lints via
`Dockerfile.lint` and then runs `make test` and `make build` in the
`Dockerfile` image, 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
@@ -160,11 +214,11 @@ 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 the lint and format checks but not the full `make check`. This is
deliberate so the TDD red-phase commit (failing tests, no implementation yet)
can land. The full `make check` runs as part of the image build, which is
what CI executes via `script/cibuild`, so a red branch still cannot reach
`main`.
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
suite runs as part of the image build, which is what CI executes via
`script/cibuild`, so a red branch still cannot reach `main`.
## Design
@@ -191,7 +245,8 @@ quak/
quak.ts CLI entrypoint (commander.js)
test/ unit + integration tests (vitest)
Makefile
Dockerfile
Dockerfile test suite and compile
Dockerfile.lint eslint and prettier, as build steps
package.json
tsconfig.json
```
@@ -268,6 +323,7 @@ Endpoints used:
- `GET /collections/v2/diff?collectionID=<id>&sinceTime=<usec>`: list files in a
collection; paginate while `hasMore` is true.
- `GET https://files.ente.io/?fileID=<id>`: download encrypted file bytes.
- `POST /files/data/fetch`: fetch encrypted ML data for a batch of files.
- `POST /files/upload-url`: mint a presigned upload URL (for thumbnail repair).
- `PUT /files/thumbnail`: register an uploaded thumbnail's object key.
@@ -307,35 +363,41 @@ 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.
Two deadlines, applied with `AbortSignal.timeout()` and renewed for each
attempt:
Two deadlines, renewed for each attempt:
| Option | Default | Applies to |
| ------------------- | -------- | ------------------------------------------- |
| `requestTimeoutMs` | `30000` | `getJSON`, `postJSON`, `putJSON`, `putFile` |
| `downloadTimeoutMs` | `600000` | file and thumbnail body transfers |
| Option | Default | Applies to | Kind |
| ------------------- | ------- | ------------------------------------------- | ------------------------------------- |
| `requestTimeoutMs` | `30000` | `getJSON`, `postJSON`, `putJSON`, `putFile` | the whole request |
| `downloadTimeoutMs` | `60000` | file and thumbnail downloads | idle: no bytes received for this long |
They are separate because one number cannot serve both: a value short enough to
keep a hung API call from stalling a backup would cancel a legitimate
multi-gigabyte download. The download deadline covers the body, not just the
headers — `getFileStream` returns as soon as headers arrive, so a deadline that
only guarded the initial request would leave the same hang one layer down.
They are different kinds because a download's length depends on the file and the
link: a whole-transfer deadline short enough to catch a hung connection would
cancel a large video on a slow link that is still making progress. The download
deadline restarts every time bytes arrive, so a slow download runs as long as it
keeps moving, and one that stalls is aborted after 60 seconds of silence. It
covers the wait for the headers and the body — `getFileStream` returns as soon
as headers arrive, so a deadline that only guarded the initial request would
leave the same hang one layer down. There is no limit on the total length of a
download.
**Non-idempotent requests are not blindly replayed.** `postJSON` and `putJSON`
reach `/users/srp/create-session`, `/users/two-factor/verify` — which consumes
one of a small number of second-factor attempts — and `/files/thumbnail`. They
are retried only on the three failures that establish no TCP connection to the
server ever existed, so no request byte can have been transmitted: `ENOTFOUND`
and `EAI_AGAIN` (name resolution produced no address) and `ECONNREFUSED` (the
peer refused the connection). A 5xx, a mid-flight reset and a deadline are all
left to the caller, because each of them can happen after the server has already
acted. The 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.
send every `POST` and `PUT` in the endpoint list above; some of them change
server state, and `/users/two-factor/verify` consumes one of a small number of
second-factor attempts. They are retried only when every errno in the error's
`cause` chain is one of the three that establish no TCP connection to the server
ever existed, so no request byte can have been transmitted: `ENOTFOUND` and
`EAI_AGAIN` (name resolution produced no address) and `ECONNREFUSED` (the peer
refused the connection). A 5xx, a mid-flight reset and a deadline are all left
to the caller, because each of them can happen after the server has already
acted. These two do not follow redirects either: a redirect means the server
already received the request, so it is reported as an error and not retried. The
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.
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
@@ -367,17 +429,23 @@ decides how to persist sessions.
`client.toJSON()` returns a `ClientSnapshot` (a plain serializable object with
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.
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.
The CLI stores the snapshot at the platform-appropriate data directory via
`env-paths`: `~/Library/Application Support/quak/session.json` on macOS,
`$XDG_DATA_HOME/quak/session.json` on Linux. The file is written with mode
`0600`. The key material is stored in cleartext in the JSON; treat this file as
you would treat the password itself.
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.
### CLI surface
```
quak [--cache-dir <path>] <command> global: local metadata/content cache location
quak login interactive or QUAK_EMAIL/QUAK_PASSWORD
quak whoami print logged-in account as JSON
quak logout delete saved session
@@ -386,13 +454,28 @@ 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-metadata <dir> [--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
```
`get` and `get-thumb` search all collections for the file ID when `--collection`
is not specified. All listing and backup commands support `--json` for
machine-readable output.
Every command runs on the same cache-backed library. The read commands —
`collections`, `files`, `get`, and `get-thumb` — force a fresh server round-trip
before they answer, so they report current account state rather than whatever
the cache last held. `--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. `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.
`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
distinct from a `failed` repair, and does not affect the exit code; a genuine
failure still exits non-zero.
### Backup layout
@@ -414,11 +497,21 @@ appears in. 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.
Each original is copied to a temporary file named
`.quak-backup-<fileID>.<ext>-<pid>-<random>.tmp` 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 run that is killed can leave one of these temporary
files behind; the next backup deletes those whose process is no longer running.
Downloads and the content cache use the same scheme with `.quak-<random>.tmp`
names. 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
- [x] Retry policy: no retry on 4xx, exponential backoff on 5xx and network
errors
- [ ] Update the API reference section below to match the current implementation
- [x] Update the API reference section below to match the current implementation
- [x] `make docker` green
- [ ] Tag `v1.0.0`
@@ -432,25 +525,182 @@ Future (desktop client, separate repo):
## API reference
The API reference section below is from an earlier draft and does not fully
reflect the current implementation. The authoritative API documentation is in
the test files, particularly `test/client/usage.test.ts` which is a literate
tutorial walking through every operation. Run `yarn test` to verify the examples
are correct.
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.
The key types and their actual signatures can be found in:
### Opening a library
`Library.open(options)` loads the on-disk cache, starts the background refresh
loop, and resolves to a `Library`. On an empty cache it awaits the first refresh
so it never opens onto empty data; on an existing cache it returns immediately
and refreshes in the background, so an unreachable server does not block
opening.
`LibraryOptions`:
| Option | Default | Meaning |
| ------------------------ | --------------------------- | --------------------------------------------------------------------- |
| `client` | required | the account client (a `Client`, or any `LibraryClient`) |
| `cacheDirectory` | `<XDG cache>/quak/<userID>` | 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) |
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).
`lib.status()` returns a `LibraryStatus` (collection/file counts, last
refresh/ML times and errors, originals usage and effective limit, precache
progress, and `closed`). `lib.close()` stops the background timer; it is
idempotent, and an in-flight refresh is left to finish. The promise it returns
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.
### 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.
`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).
### Read surface
- `lib.albums.list()``Album[]`, newest-updated first.
`lib.albums.byID({ collectionID })` and `byName({ albumName })`
`Album | undefined`.
- `lib.photos.byID({ fileID })``Photo | undefined`.
`lib.photos.records({ fileIDs })``PhotoRecord[]` in the requested order,
each id once, unknown ids dropped.
- `lib.timeline.groups({ groupBy, filter? })``TimelineGroup[]`, grouped by
`"day" | "week" | "month"` (keys `YYYY-MM-DD`, ISO `YYYY-Www`, `YYYY-MM`),
newest group first. A `PhotoFilter` combines `albumID`, `text`
(title/caption/album-name substring), `fileTypes`, `hasLocation`, and
`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:
- `await photo.original(opts?)``{ path, bytes }` — the full-resolution file.
- `await photo.thumbnail(opts?)``{ path, bytes }`.
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.
Lower-level accessors that return decrypted model objects (which hold key
material) are also available: `listCollections()`, `getCollection(id)`,
`listFiles(collectionID)`, `getFile(collectionID, fileID)`, and
`getFileByID(fileID)`.
### Records and change notifications
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.
- `AlbumRecord`: `collectionID`, `name`, `type`, `isShared`, `updationTime`, and
`fileIDs` (newest first).
- `LibrarySnapshot`: `{ albums, photos, takenAt }`.
`lib.snapshot()` returns a `LibrarySnapshot` (albums newest-updated first,
photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
(`albumsChanged`, `photosChanged`, `fileIDsRemoved`, `albumIDsRemoved`,
`refreshedAt`) whenever a refresh alters the projection, and returns
`{ unsubscribe }`; a refresh that changes nothing delivers nothing.
### Thumbnails, ML search, and backup
- `lib.thumbnails.ensure({ fileIDs, priority, signal?, onProgress? })`
prefetches thumbnails through the thumbnail pool, deduped by fileID, returning
one `EnsureResult` (`{ fileID, path?, error? }`) per file. `priority` is
`"visible" | "ahead" | "background"`; only `"visible"` preempts background
work.
- `lib.mldata` searches the CLIP index built from Ente's per-file ML data:
`forFile({ fileID })``Promise<MLData | undefined>` (the whole stored
payload — face boxes, landmarks, embedding — read from disk on demand);
`similar({ fileID, limit? })` and `searchByEmbedding({ embedding, limit? })`
`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 (and, with `includeThumbnails`, thumbnails) through the
content cache, and rebuilds the on-disk backup tree with a durable failure
ledger. `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
`RequestPools` holds three independent bounded pools — metadata (10), content
(5), thumbnails (25) — because Ente meters these traffic classes differently.
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.
### On-disk cache layout
Under `cacheDirectory`:
```
<cacheDirectory>/
metadata.json decrypted account state + refresh cursor
originals/<fileID>.<ext> cached full-resolution files
thumbnails/<fileID>.jpg cached thumbnails
mldata/
<fileID>.json one decrypted ML payload per file
clip.f32, clip.json the packed CLIP index and its id list
fetched.json per-file fetch bookkeeping
```
A stored file appears only via an atomic temp-then-rename, so its presence means
it is complete. The design also calls for a content-hash comparison against
`FileMetadata.hash` on each fetched original; that check is deferred (issue
https://git.eeqj.de/sneak/quak/issues/68) because the exact hash construction
cannot yet be confirmed against the repo's fixtures.
### Key types by source file
- `src/library/index.ts`: `Library`, `LibraryOptions`, `LibraryStatus`,
`LibraryClient`, `RefreshEvent`
- `src/library/read.ts`: `Album`, `Photo`, `AlbumsAPI`, `PhotosAPI`,
`TimelineAPI`, `PhotoFilter`, `TimelineGroup`, `GroupBy`
- `src/library/content.ts`: `ContentResult`, `ContentOptions`, `ThumbnailsAPI`,
`EnsureOptions`, `EnsureResult`, `ContentSource`
- `src/library/records.ts`: `PhotoRecord`, `AlbumRecord`, `LibrarySnapshot`,
`LibraryChange`
- `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult`
- `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool`
- `src/backup.ts`: `BackupOptions`, `BackupResult`, `BackupError`
- `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot`
- `src/api/client.ts`: `ApiClient`, `ApiClientOptions`, `ApiError`,
`StreamOptions`
- `src/api/client.ts`: `ApiClient`, `ApiClientOptions`, `StreamOptions`
- `src/errors.ts`: `ApiError`, `TruncatedStreamError`
- `src/retry.ts`: `withRetry`, `isRetryable`, `isSafeToReplay`, `RetryOptions`
- `src/auth/types.ts`: `KeyAttributes`, `SRPAttributes`,
`AuthorizationResponse`, `LoginChallenge`
- `src/model/types.ts`: `Collection`, `EnteFile`, `FileMetadata`, `FileBlob`,
`RawCollection`, `RawEnteFile`, `RawMagicMetadata`
- `src/download/index.ts`: `DownloadResult`
- `src/backup.ts`: `BackupResult`, `BackupError`
- `src/model/types.ts`: `Collection`, `EnteFile`, `FileMetadata`, `FileType`,
`CollectionType`, `RawCollection`, `RawEnteFile`
- `src/thumbnails.ts`: `MissingThumbnailInfo`, `ThumbnailFixResult`
## Source attribution
@@ -478,9 +728,14 @@ documents:
implementation. Tests are the canonical API documentation and must be
commented thoroughly. `main` is always green.
- **Required checks before every commit:** `make lint` (eslint + prettier check)
and `make fmt-check` must pass. The pre-commit hook enforces this.
`make check` (which also runs tests) must pass before merging to `main`.
- **Required checks before every commit:** `make lint` must pass — that is
eslint plus the prettier check, and it builds `Dockerfile.lint`, 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 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.
- **Formatting:** prettier with 4-space indents and `proseWrap: always` for
markdown. Use `make fmt` to format. Use `yarn` not `npm`.
+144 -2
View File
@@ -14,10 +14,153 @@ pre-1.0
# Next Step
Update the README API reference section to match the current implementation.
Tag v1.0.0.
# Completed Steps
- 2026-09-23: Fixed two intermittently failing library tests (issue 90).
`Library.close()` now returns a promise that resolves once an in-flight
refresh (including its cache write), the ML data fetch and running precache
sweeps have finished; the library tests await it, so `afterEach` no longer
removes the cache directory while something is still writing into it. The
precache test waits for both fills to report "done" instead of for its stub
source to be called, which happened before the cache recorded the file.
- 2026-09-23: Pinned three guards reviewers found untested (issue 89). The
download idle deadline's timer is unref'd, so it can never keep the process
alive, and a test checks no timer is left after a download completes or fails.
A test covers the rejection of `#` in a request path. The EXIF scan compares
the `Exif` header only in an APP1 segment of length 8 or more, so it never
reads the next segment's bytes, with a test for a short one.
- 2026-09-23: Made the download deadline an idle deadline (issue 24).
`downloadTimeoutMs` now aborts a file or thumbnail download only after no
bytes have arrived for that long, default 60 seconds, instead of bounding the
whole transfer at 10 minutes, so a slow download that keeps making progress
completes. A download that fails before reading the whole body cancels it, so
a failed file no longer holds its connection.
- 2026-09-23: Every `ApiClient` request URL is now built by one function next to
the class (issue 18), so a self-hosted `apiOrigin` with a base path keeps it
on every request, a path works with or without a leading slash, and query
parameters are percent-encoded. A path containing `?` or `#` is rejected with
an error instead of being silently cut.
- 2026-09-23: Made the CLI testable and tested it (issue 12). The command bodies
moved from `bin/quak.ts` into `src/cli-commands.ts` as functions that take
their options and a context (output streams, session directory, cache
directory, session loader) and return an exit code; `bin/quak.ts` only wires
them to commander and exits with the code once stdout and stderr have drained,
so nothing below it calls `process.exit`. `test/cli/commands.test.ts` drives
them with a fake client: session file modes, logout, the missing and corrupt
session paths, and the output and exit code of `whoami`, `collections`,
`files`, `get`, `get-thumb`, `backup` and `helper list-missing-thumbnails`.
- 2026-09-23: Hardened the backup tree's atomic copy (issue 22). `copyAtomic`
fsyncs its temp file before the rename and the directory after it, through the
download writer's `fsyncPath`; each backup run deletes `.quak-backup-*.tmp`
files whose process is no longer running. The README backup layout names the
temp files and states that the rename replaces a symlink and takes the temp
file's permissions. Added tests for a missing and an unwritable destination
directory for `downloadFile` and `downloadThumbnail`.
- 2026-09-23: Hardened the JPEG EXIF scan behind `backup-metadata --exif` (issue
11). Every segment length is checked against the remaining bytes and lengths
under 2 stop the scan, so a truncated or corrupt original can neither throw
nor loop. A malformed or unparseable EXIF segment is recorded as
`imageMetadata.exifError`, and a failure to read the original as
`imageMetadataError` in the per-file JSON, instead of the field being left
out.
- 2026-09-22: Hardened the retry classifier (issue 80). A `POST` or `PUT` is
replayed only when every errno in the cause chain is a connect errno, and it
no longer follows redirects. `getRetryOptions()` returns a copy. Tests pin
every errno the classifier names, the cause-chain depth limit, cycle
termination, and a fresh deadline per attempt for every retrying entry point.
The README's endpoint list is the one place that names the requests the replay
rule covers.
- 2026-09-22: Stopped `make test` collecting tests from checkouts nested under
`.claude/` (issue 25). vitest ignores `.gitignore` when finding tests, so a
nested checkout ran the whole suite again; `vitest.config.ts` now adds
`.claude/**` to vitest's default excludes, and
`test/packaging/nested-checkout.test.ts` plants a nested checkout in a temp
directory and fails if vitest would collect it.
- 2026-09-22: Dropped the deprecated `@types/libsodium-wrappers-sumo` stub from
`devDependencies` (issue 27). It shipped no declarations; the types come from
`libsodium-wrappers-sumo` itself. `yarn.lock` regenerated by `yarn remove`.
- 2026-09-22: Hardened the client session lifecycle (issue 10).
`Client.fromJSON` checks every snapshot field and each key's decoded length
and names the bad field; `toJSON` reads the token through
`ApiClient.getAuthToken` and throws when there is none; `logout` zeroes the
key buffers, and `collectionsSince` re-checks for logout after its request so
it never decrypts with zeroed keys. The CLI reports a corrupt session file
separately from a missing one (`src/cli-session.ts`).
- 2026-09-22: Sanitized file names taken from server metadata (issue 9). A new
`src/filename.ts` holds the one sanitizer, used by `quak get`/`get-thumb`
without `--out`, `downloadFile`/`downloadThumbnail` without `outPath`, and the
backup and metadata backup trees; it removes separators, control characters,
leading dots and Windows device names, and falls back to a name built from the
ID for an empty title. Originals-cache extensions are letters and digits only,
else `.bin`. A user-supplied path is used as is. `decryptFile` reads a missing
or non-string title as "" and rejects metadata that is not a JSON object.
- 2026-09-22: Rewrote the README API reference (and the Getting Started / usage
snippets) to match the shipped cache/API library on `next` (issue 53, issue
13). Documented `Library.open` and its options, the default-read vs `fresh()`
distinction (and that the CLI's read commands are fresh), the record types and
`snapshot()`/`subscribe()`, the `albums`/`photos`/`timeline` read surface,
`Photo` content methods, `thumbnails.ensure`, the `mldata` search surface,
`backup()`, the three request pools (10/5/25), and the on-disk cache layout;
noted the deferred content-hash integrity check (issue 68). Docs-only; no code
changed.
- 2026-09-22: Added resumable, deletion-aware enumeration to `Client` (issue 38,
closes issue 7). `collectionsSince`/`filesSince` take a starting cursor,
decrypt live records, surface tombstoned ids in a separate `deleted` list (a
tombstone has nothing to decrypt, so it is a bare id, not a hollow record),
and return the max `updationTime` seen as the cursor to resume from.
`filesSince` refuses to loop when the diff reports `hasMore` without advancing
the cursor (issue 7). `listCollections`/`listFiles` are now thin wrappers that
enumerate from `sinceTime: 0` and drop deletions, so existing callers are
unaffected.
- 2026-09-22: Carried file size, thumbnail size, and the deletion flag through
`decryptFile` (issue 37, foundation for the cache/API design). Live files now
populate `file.size`/`thumbnail.size` from the server's `info` (left
`undefined` when the server omits it), and `isDeleted` is carried from the
diff row onto `EnteFile`. No caller change: `listFiles` still filters deleted
rows before decrypting. Surfacing a tombstone through decryption belongs to
the enumeration unit (issue 38).
- 2026-08-10: Made `lint-once.test.ts` enforce what its header claims. It walked
`make check` only, so it never read `Dockerfile` — the image CI builds through
`script/cibuild` — and a second `prettier --check .` could be added there with
the suite staying green. The walk now also starts at
`.gitea/workflows/check.yml` and follows its `run:` steps, so the graph under
test is the one CI executes rather than the one someone assumed it executes.
The lockfile assertion was a substring check against the whole of
`script/bootstrap`, which has two install sites and so reported the branch the
containers never take; the two branches are now resolved separately and every
`yarn install` in each is required to be `--frozen-lockfile`. Prettier is
counted per occurrence instead of per line, so two invocations chained with
`&&` no longer read as one, and edges are followed on counted lines instead of
being skipped. Every way for the walk to reach nothing — an unknown target, an
unknown script, a missing file, a node with no commands, an unknown node kind
— is a thrown error rather than a quiet zero. Every assertion in the file was
mutation-tested individually.
- 2026-08-10: Stopped `make check` running `prettier --check .` twice. Since
linting moved into Docker, the duplicate was one container pass and one host
pass of the same check: `script/lint` builds `Dockerfile.lint`, which runs
prettier as a build step, and `script/check` then called `script/fmt-check` as
well. The host call is gone from `script/check` and from `script/precommit`;
the container keeps checking formatting, because a successful
`Dockerfile.lint` build is what CI treats as proof of a clean tree, and it is
also what still fails the pre-commit hook on a badly formatted tree.
`script/fmt-check` survives as a standalone entrypoint, whose verdict cannot
drift from the container's. A test walks the invocation graph from each
entrypoint — through the Makefile shims, the `script/` calls and the
`docker build` — and asserts the prettier count, so the duplication cannot
come back unnoticed.
- 2026-08-10: Moved all linting into Docker. `script/lint` builds a new root
`Dockerfile.lint`, which copies the repo into the digest-pinned node image and
runs eslint and prettier as build steps, so a successful build is a clean
lint; no host lint path remains and `yarn lint` is gone from `package.json`. A
fail-closed `LINT_EPOCH` guard stops Docker serving the linter layers from
cache, which is how a lint build returns success in under a second having
linted nothing. The lint stage inside `Dockerfile` and its `COPY --from=lint`
ordering hack are gone: that image now runs `make test` and `make build` only,
because `script/check` calls `script/lint` and running it in a container would
mean docker inside docker. `script/cibuild` builds the lint image first, then
the test and build image.
- 2026-08-09: Made `make docker` green and policy-conformant. Multi-stage
Dockerfile: a lint stage runs `make fmt-check` and `make lint`, and the check
stage takes a `COPY --from=lint` dependency on it before running `make check`
@@ -66,7 +209,6 @@ Update the README API reference section to match the current implementation.
# Future Steps
- Tag v1.0.0.
- Future desktop client, separate repo:
- Electron app skeleton consuming this library.
- Local SQLite cache keyed on (collectionID, fileID, updationTime).
+69 -334
View File
@@ -1,146 +1,87 @@
#!/usr/bin/env node
import { input, password as passwordPrompt } from "@inquirer/prompts";
import { stdout, stderr } from "node:process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { Command } from "commander";
import envPaths from "env-paths";
import { Client, type ClientSnapshot } from "../src/client.js";
import { init } from "../src/crypto/index.js";
import { runBackup } from "../src/backup.js";
import { runMetadataBackup } from "../src/metadata-backup.js";
import {
listMissingThumbnails,
fixMissingThumbnails,
} from "../src/thumbnails.js";
type CliContext,
loginCommand,
whoamiCommand,
logoutCommand,
collectionsCommand,
filesCommand,
getCommand,
getThumbCommand,
backupMetadataCommand,
backupCommand,
listMissingThumbnailsCommand,
fixMissingThumbnailsCommand,
} from "../src/cli-commands.js";
import { loadSession } from "../src/cli-session.js";
const paths = envPaths("quak", { suffix: "" });
const sessionPath = join(paths.data, "session.json");
const loadSession = (): ClientSnapshot | null => {
if (!existsSync(sessionPath)) return null;
try {
return JSON.parse(readFileSync(sessionPath, "utf-8")) as ClientSnapshot;
} catch {
return null;
}
};
const saveSession = (snapshot: ClientSnapshot): void => {
mkdirSync(paths.data, { recursive: true, mode: 0o700 });
writeFileSync(sessionPath, JSON.stringify(snapshot, null, 2), {
mode: 0o600,
});
};
const requireSession = (): Client => {
const snapshot = loadSession();
if (!snapshot) {
stderr.write(
`Not logged in. Run "quak login" first.\nSession file: ${sessionPath}\n`,
);
process.exit(1);
}
return Client.fromJSON(snapshot);
};
const prompt = async (message: string): Promise<string> => input({ message });
const promptSecret = async (message: string): Promise<string> =>
passwordPrompt({ message, mask: true });
const program = new Command();
program
.name("quak")
.description("CLI for the Ente end-to-end encrypted photo service")
.version("0.0.0");
.version("0.0.0")
.option(
"--cache-dir <path>",
"Directory for the local metadata/content cache " +
"(default: the per-user cache directory)",
);
const context = (): CliContext => ({
stdout,
stderr,
sessionDir: paths.data,
cacheDir: program.opts<{ cacheDir?: string }>().cacheDir,
loadSession,
});
// 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.
const run = async (command: Promise<number>): Promise<void> => {
process.exitCode = await command;
const pending = [stdout, stderr].filter((s) => s.writableLength > 0);
if (pending.length === 0) {
process.exit();
return;
}
let remaining = pending.length;
for (const s of pending) {
s.once("drain", () => {
if (--remaining === 0) process.exit();
});
}
};
program
.command("login")
.description("Log in to an Ente account and save the session")
.action(async () => {
await init();
const email = process.env.QUAK_EMAIL ?? (await prompt("Email"));
const password =
process.env.QUAK_PASSWORD ?? (await promptSecret("Password"));
stderr.write("Authenticating...\n");
try {
const client = await Client.login({
email,
password,
totp: async () => prompt("TOTP code: "),
emailOTP: async () => prompt("Email verification code: "),
});
saveSession(client.toJSON());
const info = client.whoami();
stderr.write(`Logged in as ${info.email} (user ${info.userID})\n`);
stderr.write(`Session saved to ${sessionPath}\n`);
} catch (err) {
stderr.write(
`Login failed: ${err instanceof Error ? err.message : err}\n`,
);
process.exit(1);
}
});
.action(() => run(loginCommand(context())));
program
.command("whoami")
.description("Print the logged-in account")
.action(() => {
const client = requireSession();
const info = client.whoami();
stdout.write(JSON.stringify(info) + "\n");
});
.action(() => run(whoamiCommand(context())));
program
.command("logout")
.description("Delete the saved session")
.action(async () => {
if (existsSync(sessionPath)) {
const { unlinkSync } = await import("node:fs");
unlinkSync(sessionPath);
stderr.write("Session deleted.\n");
} else {
stderr.write("No session found.\n");
}
});
.action(() => run(logoutCommand(context())));
program
.command("collections")
.description("List all collections (albums)")
.option("--json", "Output as JSON array")
.action(async (opts: { json?: boolean }) => {
await init();
const client = requireSession();
const collections = await client.listCollections();
if (opts.json) {
stdout.write(
JSON.stringify(
collections.map((c) => ({
id: c.id,
name: c.name,
type: c.type,
ownerID: c.ownerID,
isShared: c.isShared,
updationTime: c.updationTime,
})),
null,
2,
) + "\n",
.action((opts: { json?: boolean }) =>
run(collectionsCommand(context(), opts)),
);
} else {
for (const c of collections) {
stdout.write(
`${c.id}\t${c.type}\t${c.name}${c.isShared ? " (shared)" : ""}\n`,
);
}
}
});
program
.command("files")
@@ -150,93 +91,18 @@ program
"Collection ID (from `quak collections`)",
)
.option("--json", "Output as JSON array")
.action(async (opts: { collection: string; json?: boolean }) => {
await init();
const client = requireSession();
const collectionID = Number(opts.collection);
if (!Number.isFinite(collectionID)) {
stderr.write("Invalid collection ID\n");
process.exit(1);
}
const collections = await client.listCollections();
const col = collections.find((c) => c.id === collectionID);
if (!col) {
stderr.write(`Collection ${collectionID} not found\n`);
process.exit(1);
}
const files = await client.listFiles(col.id, col.key);
if (opts.json) {
stdout.write(
JSON.stringify(
files.map((f) => ({
id: f.id,
title: f.metadata.title,
fileType: f.metadata.fileType,
creationTime: f.metadata.creationTime,
collectionID: f.collectionID,
})),
null,
2,
) + "\n",
.action((opts: { collection: string; json?: boolean }) =>
run(filesCommand(context(), opts)),
);
} else {
for (const f of files) {
stdout.write(
`${f.id}\t${f.metadata.fileType}\t${f.metadata.title}\n`,
);
}
}
});
program
.command("get")
.description("Download and decrypt a single file")
.argument("<fileID>", "File ID (from `quak files`)")
.option("--out <path>", "Output file path")
.option(
"--collection <id>",
"Collection ID (required to look up the file key)",
)
.action(
async (
fileIDStr: string,
opts: { out?: string; collection?: string },
) => {
await init();
const client = requireSession();
const fileID = Number(fileIDStr);
if (!Number.isFinite(fileID)) {
stderr.write("Invalid file ID\n");
process.exit(1);
}
const collections = await client.listCollections();
let targetCol;
if (opts.collection) {
targetCol = collections.find(
(c) => c.id === Number(opts.collection),
);
}
// Search all collections (or the specified one) for the file
const searchCols = targetCol ? [targetCol] : collections;
for (const col of searchCols) {
const files = await client.listFiles(col.id, col.key);
const file = files.find((f) => f.id === fileID);
if (file) {
const result = await client.downloadFile(file, opts.out);
stderr.write(
`${result.bytesWritten} bytes -> ${result.path}\n`,
);
return;
}
}
stderr.write(`File ${fileID} not found\n`);
process.exit(1);
},
.option("--collection <id>", "Accepted for compatibility; ignored")
.action((fileID: string, opts: { out?: string }) =>
run(getCommand(context(), fileID, opts)),
);
program
@@ -244,49 +110,9 @@ program
.description("Download and decrypt a thumbnail")
.argument("<fileID>", "File ID (from `quak files`)")
.option("--out <path>", "Output file path")
.option(
"--collection <id>",
"Collection ID (required to look up the file key)",
)
.action(
async (
fileIDStr: string,
opts: { out?: string; collection?: string },
) => {
await init();
const client = requireSession();
const fileID = Number(fileIDStr);
if (!Number.isFinite(fileID)) {
stderr.write("Invalid file ID\n");
process.exit(1);
}
const collections = await client.listCollections();
let targetCol;
if (opts.collection) {
targetCol = collections.find(
(c) => c.id === Number(opts.collection),
);
}
const searchCols = targetCol ? [targetCol] : collections;
for (const col of searchCols) {
const files = await client.listFiles(col.id, col.key);
const file = files.find((f) => f.id === fileID);
if (file) {
const result = await client.downloadThumbnail(
file,
opts.out,
);
stderr.write(
`${result.bytesWritten} bytes -> ${result.path}\n`,
);
return;
}
}
stderr.write(`File ${fileID} not found\n`);
process.exit(1);
},
.option("--collection <id>", "Accepted for compatibility; ignored")
.action((fileID: string, opts: { out?: string }) =>
run(getThumbCommand(context(), fileID, opts)),
);
program
@@ -300,14 +126,9 @@ program
"Download each file and extract full EXIF/IPTC/XMP metadata (slow)",
)
.option("--all", "Alias for --exif")
.action(async (dir: string, opts: { exif?: boolean; all?: boolean }) => {
await init();
const client = requireSession();
await runMetadataBackup(client, dir, {
exif: opts.exif || opts.all,
onProgress: (msg) => stderr.write(msg + "\n"),
});
});
.action((dir: string, opts: { exif?: boolean; all?: boolean }) =>
run(backupMetadataCommand(context(), dir, opts)),
);
program
.command("backup")
@@ -316,35 +137,9 @@ program
)
.argument("<dir>", "Output directory")
.option("--json", "Print result as JSON instead of human-readable summary")
.action(async (dir: string, opts: { json?: boolean }) => {
await init();
const client = requireSession();
stderr.write("Starting backup...\n");
const result = await runBackup(client, dir, (msg) => {
if (!opts.json) stderr.write(msg + "\n");
});
if (opts.json) {
stdout.write(JSON.stringify(result, null, 2) + "\n");
} else {
stderr.write("\n--- Backup complete ---\n");
stderr.write(` Total files: ${result.totalFiles}\n`);
stderr.write(` Downloaded: ${result.downloaded}\n`);
stderr.write(` Skipped: ${result.skipped}\n`);
stderr.write(` Failed: ${result.failed}\n`);
if (result.errors.length > 0) {
stderr.write("\nFailed files:\n");
for (const e of result.errors) {
stderr.write(
` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`,
.action((dir: string, opts: { json?: boolean }) =>
run(backupCommand(context(), dir, opts)),
);
}
}
}
process.exit(result.failed > 0 ? 1 : 0);
});
const helper = program
.command("helper")
@@ -354,30 +149,9 @@ helper
.command("list-missing-thumbnails")
.description("List files whose thumbnails are missing or empty")
.option("--json", "Output as JSON array")
.action(async (opts: { json?: boolean }) => {
await init();
const client = requireSession();
const missing = await listMissingThumbnails(client, (msg) => {
if (!opts.json) stderr.write(msg + "\n");
});
if (opts.json) {
stdout.write(JSON.stringify(missing, null, 2) + "\n");
} else {
if (missing.length === 0) {
stderr.write("No missing thumbnails found.\n");
} else {
stderr.write(
`\n${missing.length} file(s) with missing thumbnails:\n`,
.action((opts: { json?: boolean }) =>
run(listMissingThumbnailsCommand(context(), opts)),
);
for (const m of missing) {
stdout.write(
`${m.fileID}\t${m.title}\t${m.collection}\t${m.reason}\n`,
);
}
}
}
});
helper
.command("fix-missing-thumbnails")
@@ -389,48 +163,9 @@ helper
"Specific file IDs to fix (default: fix all missing)",
)
.option("--json", "Output as JSON")
.action(async (opts: { file?: string[]; json?: boolean }) => {
await init();
const client = requireSession();
let fileIDs: number[];
if (opts.file && opts.file.length > 0) {
fileIDs = opts.file.map(Number).filter(Number.isFinite);
} else {
stderr.write("Scanning for missing thumbnails...\n");
const missing = await listMissingThumbnails(client, (msg) => {
if (!opts.json) stderr.write(msg + "\n");
});
fileIDs = missing.map((m) => m.fileID);
if (fileIDs.length === 0) {
stderr.write("No missing thumbnails found.\n");
return;
}
stderr.write(`Found ${fileIDs.length} file(s) to fix.\n`);
}
const results = await fixMissingThumbnails(client, fileIDs, (msg) => {
if (!opts.json) stderr.write(msg + "\n");
});
if (opts.json) {
stdout.write(JSON.stringify(results, null, 2) + "\n");
} else {
const ok = results.filter((r) => r.success).length;
const fail = results.filter((r) => !r.success).length;
stderr.write(`\n--- Done ---\n`);
stderr.write(` Fixed: ${ok}\n`);
stderr.write(` Failed: ${fail}\n`);
if (fail > 0) {
stderr.write("\nFailed files:\n");
for (const r of results.filter((r) => !r.success)) {
stderr.write(` ${r.fileID}\t${r.title}\t${r.error}\n`);
}
}
}
process.exit(results.some((r) => !r.success) ? 1 : 0);
});
.action((opts: { file?: string[]; json?: boolean }) =>
run(fixMissingThumbnailsCommand(context(), opts)),
);
await init();
program.parse();
-2
View File
@@ -24,13 +24,11 @@
"build": "script/build",
"quak": "node ./dist/bin/quak.js",
"test": "vitest run",
"lint": "eslint .",
"fmt": "prettier --write .",
"fmt-check": "prettier --check ."
},
"devDependencies": {
"@eslint/js": "9.38.0",
"@types/libsodium-wrappers-sumo": "0.8.2",
"@types/node": "22.18.13",
"eslint": "9.38.0",
"prettier": "3.8.1",
+15 -3
View File
@@ -1,6 +1,19 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. Must not modify any files.
# script/check: run all checks (test, lint). Our own extension to
# scripts-to-rule-them-all. Must not modify any files.
#
# The formatting check is part of lint, not a step of its own:
# script/lint builds Dockerfile.lint, which runs eslint AND
# `prettier --check .` as build steps. Calling script/fmt-check here as
# well would run prettier a second time over the same tree for the same
# verdict — the weaker of the two, since the host toolchain is whatever
# the working tree happens to have installed while the container's is
# digest-pinned. script/fmt-check remains a standalone entrypoint for
# asking the formatting question by itself.
#
# script/lint builds Dockerfile.lint, so this script requires docker and
# must never be run from inside a container: that is why the Dockerfile
# image runs script/test and script/build rather than this.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -8,7 +21,6 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"
+14 -7
View File
@@ -1,16 +1,23 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs script/check and
# script/build, and CHECK_EPOCH differs on every invocation, so those two
# layers cannot be served from Docker's cache: a green build here means
# the checks ran now, not that a previous run was remembered. The layers
# below the epoch (bootstrap, yarn install) are unaffected and stay
# cached. A build that omits the argument fails by design.
# script/cibuild: run the CI build, which is both images in a defined order.
#
# First script/lint, which builds Dockerfile.lint and is the one and only
# place linting happens — it goes first so a lint failure is reported before
# the slower suite runs. Then the Dockerfile image, which runs script/test
# and script/build. CHECK_EPOCH and LINT_EPOCH differ on every invocation, so
# neither the linters nor the suite can be served from Docker's cache: a
# green build here means the checks ran now, not that a previous run was
# remembered. The layers below the epochs (bootstrap, yarn install) are
# unaffected and stay cached. A build that omits the arguments fails by
# design.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
"$SCRIPT_DIR/lint"
docker build --build-arg CHECK_EPOCH="$(date +%s)" .
}
+2 -1
View File
@@ -3,7 +3,8 @@
# Identical in all repos; the tag comes from script/projectname.
# CHECK_EPOCH is passed for the same reason script/cibuild passes it: the
# Dockerfile refuses to build without it, so that no path to an image can
# quietly serve the check and build layers from cache.
# quietly serve the test and build layers from cache. This builds the test
# and build image only; linting is a separate image, built by script/lint.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
+14 -3
View File
@@ -1,13 +1,24 @@
#!/bin/sh
# script/lint: run the linter (eslint plus a prettier check).
# script/lint: run the linters. eslint and prettier are never run against
# the working tree from here: linting runs via docker only, one way,
# everywhere — script/lint builds Dockerfile.lint, which COPYs the repo into
# the pinned node image and runs the linters as build steps. That works even
# when the docker daemon is remote and bind mounts are impossible.
#
# LINT_EPOCH is passed on every invocation because no lint cache is wanted:
# on an unchanged tree Docker would otherwise serve the linter layers, having
# linted nothing, and still exit 0. Dockerfile.lint refuses to build without
# the argument, so no path to a lint result can quietly come from cache.
#
# Nothing that runs inside a container may call this script; see the header
# of Dockerfile.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
yarn run eslint .
yarn run prettier --check .
docker build --build-arg LINT_EPOCH="$(date +%s)" -f Dockerfile.lint .
}
main "$@"
+13 -5
View File
@@ -2,17 +2,25 @@
# 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 and fmt-check but deliberately NOT the tests, so the TDD
# red-phase commit (failing tests, no implementation yet) can land. CI
# runs make check via docker build, which catches any branch that
# ships red.
# Runs lint but deliberately NOT the tests, so the TDD red-phase commit
# (failing tests, no implementation yet) can land. CI runs
# script/cibuild, which builds both images and so catches any branch
# that ships red.
#
# The formatting check is still enforced here, because script/lint is a
# build of Dockerfile.lint and that runs `prettier --check .` as a build
# step: a badly formatted tree fails this hook, and therefore the
# commit. Calling script/fmt-check as well would only run prettier a
# second time over the same tree for the same verdict.
#
# script/lint is a docker build (Dockerfile.lint); docker is required to
# commit, which is the point of linting one way, everywhere.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"
+103 -53
View File
@@ -19,14 +19,12 @@ const DEFAULT_FILES_ORIGIN = "https://files.ente.io";
const DEFAULT_THUMBS_ORIGIN = "https://thumbnails.ente.io";
const CLIENT_PACKAGE = "berlin.sneak.quak";
// Two deadlines rather than one, because a single number cannot serve both
// jobs. Thirty seconds is generous for a JSON call and short enough that a
// hung API connection cannot stall a backup for long. A file body is a
// different shape of problem: the deadline has to cover the whole transfer,
// which for a large video on a slow link is minutes, so a value sane for JSON
// would cancel legitimate downloads.
// Two deadlines of different kinds. `requestTimeoutMs` bounds a whole JSON
// call. `downloadTimeoutMs` is an idle deadline: a file or thumbnail download
// is aborted only when no bytes have arrived for that long, so a large video on
// a slow link that keeps making progress is never cut off.
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
export const DEFAULT_DOWNLOAD_TIMEOUT_MS = 600_000;
export const DEFAULT_DOWNLOAD_TIMEOUT_MS = 60_000;
export interface ApiClientOptions {
apiOrigin?: string;
@@ -48,18 +46,44 @@ export interface StreamOptions {
retry?: boolean;
}
// Enforce a deadline over a response body, not merely over its headers.
// An abort signal that fires once `ms` pass without a call to `restart`. It
// aborts with a `TimeoutError`, the same reason `AbortSignal.timeout()` gives,
// so the retry classifier treats an idle download exactly as it treats any
// other deadline. `stop` must be called when the download ends. The timer is
// unref'd, so even one left running never keeps the process alive.
const idleDeadline = (ms: number) => {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const stop = (): void => clearTimeout(timer);
const restart = (): void => {
stop();
timer = setTimeout(() => {
controller.abort(
new DOMException(
`download stalled: no bytes received for ${ms} ms`,
"TimeoutError",
),
);
}, ms);
timer.unref();
};
restart();
return { signal: controller.signal, restart, stop };
};
// Enforce the idle deadline over a response body, not merely over its headers.
//
// `getFileStream` returns as soon as headers arrive; the bytes are pulled
// later, in the download layer. Whether the signal passed to `fetch` also
// tears down the body afterwards is up to the fetch implementation, so this
// wrapper makes it a property of quak instead: every read races the signal,
// and an abort errors the stream with the abort reason — which the retry
// classifier recognises.
// each chunk that arrives restarts the deadline, and an abort errors the
// stream with the abort reason — which the retry classifier recognises.
const deadlineStream = (
body: ReadableStream<Uint8Array>,
signal: AbortSignal,
deadline: ReturnType<typeof idleDeadline>,
): ReadableStream<Uint8Array> => {
const { signal } = deadline;
const reader = body.getReader();
let rejectOnAbort: (reason: unknown) => void = () => undefined;
const aborted = new Promise<never>((_resolve, reject) => {
@@ -73,7 +97,10 @@ const deadlineStream = (
const onAbort = (): void => rejectOnAbort(signal.reason);
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
const release = (): void => signal.removeEventListener("abort", onAbort);
const release = (): void => {
deadline.stop();
signal.removeEventListener("abort", onAbort);
};
return new ReadableStream<Uint8Array>({
async pull(controller) {
@@ -84,6 +111,7 @@ const deadlineStream = (
controller.close();
return;
}
deadline.restart();
controller.enqueue(next.value);
} catch (err) {
release();
@@ -98,6 +126,30 @@ const deadlineStream = (
});
};
// The one place a request URL is built. `origin` may carry a base path (a
// self-hosted server behind a prefix) and may end in a slash; `path` may or
// may not start with one. Query parameters go only through `query`, which
// percent-encodes them: a `?` or `#` in `path` is an error, because
// `new URL` would otherwise quietly treat what follows as something else.
const buildURL = (
origin: string,
path: string,
query?: Record<string, string | number | undefined>,
): string => {
if (path.includes("?") || path.includes("#")) {
throw new Error(
`request path must not contain "?" or "#"; pass query parameters separately: ${path}`,
);
}
const url = new URL(
`${origin.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`,
);
for (const [k, v] of Object.entries(query ?? {})) {
if (v !== undefined) url.searchParams.set(k, String(v));
}
return url.href;
};
export class ApiClient {
private readonly apiOrigin: string;
private readonly isCustomOrigin: boolean;
@@ -139,11 +191,16 @@ export class ApiClient {
this.token = undefined;
}
getAuthToken(): string | undefined {
return this.token;
}
// The policy this client was configured with, so that a caller wrapping a
// whole operation in its own `withRetry` — the download layer — runs under
// the same settings rather than under the library defaults.
// A copy, so the caller cannot change this client's settings through it.
getRetryOptions(): ResolvedRetryOptions {
return this.retry;
return { ...this.retry };
}
private headers(extra?: Record<string, string>): Record<string, string> {
@@ -197,22 +254,10 @@ export class ApiClient {
path: string,
query?: Record<string, string | number | undefined>,
): Promise<T> {
const url = new URL(path, this.apiOrigin + "/");
// new URL with a base resolves relative paths; ensure we keep the
// origin from apiOrigin even when path starts with /
url.protocol = new URL(this.apiOrigin).protocol;
url.host = new URL(this.apiOrigin).host;
url.pathname = path;
if (query) {
for (const [k, v] of Object.entries(query)) {
if (v !== undefined) {
url.searchParams.set(k, String(v));
}
}
}
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.href, {
const resp = await this._fetch(url, {
method: "GET",
headers: this.headers(),
signal: AbortSignal.timeout(this.requestTimeoutMs),
@@ -223,16 +268,16 @@ export class ApiClient {
}
async postJSON<T>(path: string, body: unknown): Promise<T> {
const url = `${this.apiOrigin}${path}`;
// Idempotency: this reaches `/users/srp/create-session`,
// `/users/two-factor/verify` and `/users/ott`, all of which change
// server state — verifying a second factor consumes one of a small
// number of attempts. So a POST is replayed only on a failure that
// establishes no TCP connection to the server ever existed: DNS
// produced no address, or the peer refused the connection. A 5xx, a
// mid-flight reset, a routing errno (which Linux also delivers on an
// established socket) and a timeout are all left to the caller,
// because each of them can occur after the server has already acted.
const url = buildURL(this.apiOrigin, path);
// Not idempotent: a POST is replayed only when `isSafeToReplay`
// says no request byte can have reached the server. The endpoints
// this covers are listed in the README under "Endpoints used".
//
// Redirects are not followed. The origin has already received the
// request when it answers with one, so a connection refused by the
// redirect target would look replay-safe when it is not. The API has
// no legitimate redirect, so one surfaces as an `ApiError` with its
// 3xx status, which is not retried.
return withRetry(
async () => {
const resp = await this._fetch(url, {
@@ -241,6 +286,7 @@ export class ApiClient {
"Content-Type": "application/json",
}),
body: JSON.stringify(body),
redirect: "manual",
signal: AbortSignal.timeout(this.requestTimeoutMs),
});
await this.throwIfError(resp);
@@ -255,8 +301,8 @@ export class ApiClient {
opts?: StreamOptions,
): Promise<ReadableStream<Uint8Array>> {
const url = this.isCustomOrigin
? `${this.apiOrigin}/files/download/${fileID}`
: `${this.filesOrigin}/?fileID=${fileID}`;
? buildURL(this.apiOrigin, `/files/download/${fileID}`)
: buildURL(this.filesOrigin, "/", { fileID });
return this.streamRequest(url, opts);
}
@@ -298,10 +344,8 @@ export class ApiClient {
}
async putJSON<T>(path: string, body: unknown): Promise<T> {
const url = `${this.apiOrigin}${path}`;
// Same idempotency rule as `postJSON`, for the same reason: this
// reaches `/files/thumbnail`, which registers an uploaded thumbnail
// against a file.
const url = buildURL(this.apiOrigin, path);
// Same replay and redirect rules as `postJSON`, for the same reasons.
return withRetry(
async () => {
const resp = await this._fetch(url, {
@@ -310,6 +354,7 @@ export class ApiClient {
"Content-Type": "application/json",
}),
body: JSON.stringify(body),
redirect: "manual",
signal: AbortSignal.timeout(this.requestTimeoutMs),
});
await this.throwIfError(resp);
@@ -335,8 +380,8 @@ export class ApiClient {
opts?: StreamOptions,
): Promise<ReadableStream<Uint8Array>> {
const url = this.isCustomOrigin
? `${this.apiOrigin}/files/preview/${fileID}`
: `${this.thumbsOrigin}/?fileID=${fileID}`;
? buildURL(this.apiOrigin, `/files/preview/${fileID}`)
: buildURL(this.thumbsOrigin, "/", { fileID });
return this.streamRequest(url, opts);
}
@@ -345,22 +390,27 @@ export class ApiClient {
opts?: StreamOptions,
): Promise<ReadableStream<Uint8Array>> {
const once = async (): Promise<ReadableStream<Uint8Array>> => {
// A fresh deadline per attempt, so a retry gets the whole budget
// rather than the remainder of the one that just expired.
const signal = AbortSignal.timeout(this.downloadTimeoutMs);
// A fresh deadline per attempt. It also covers the wait for the
// headers, when no bytes have arrived either.
const deadline = idleDeadline(this.downloadTimeoutMs);
try {
const resp = await this._fetch(url, {
method: "GET",
headers: this.headers(),
signal,
signal: deadline.signal,
});
await this.throwIfError(resp);
if (!resp.body) {
// Carries the status, and is not retryable: a response that
// arrived without a body is malformed, and asking again
// produces the same malformed response.
// Carries the status, and is not retryable: a response
// that arrived without a body is malformed, and asking
// again produces the same malformed response.
throw new ApiError("response body is null", resp.status);
}
return deadlineStream(resp.body, signal);
return deadlineStream(resp.body, deadline);
} catch (err) {
deadline.stop();
throw err;
}
};
return opts?.retry === false ? once() : withRetry(once, this.retry);
}
+417 -107
View File
@@ -1,13 +1,67 @@
// The backup command, rebuilt on the library API (issue #51).
//
// `lib.backup()` refreshes the library, 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, unchanged:
//
// <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
//
// Crash-safety rests on two properties. Bytes are present-means-complete: an
// original appears under `originals/` 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.
//
// 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.
import {
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
readlinkSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { join, relative, extname } from "node:path";
import type { Client } from "./client.js";
import type { EnteFile } from "./model/types.js";
import { copyFile, rename, rm } from "node:fs/promises";
import { basename, dirname, join, relative } from "node:path";
import { fsyncPath } from "./download/index.js";
import { safeExtension, sanitizeFileName } from "./filename.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.
downloadDirectory?: string;
// Fetch and store full-resolution originals. Default true.
includeOriginals?: boolean;
// Also fetch and store thumbnails under `thumbnails/<fileID>.jpg`. Default
// false.
includeThumbnails?: boolean;
// Restrict the backup to albums with these names; others are left untouched.
onlyAlbumNames?: string[];
onProgress?: ProgressCallback;
}
export interface BackupError {
fileID: number;
@@ -17,139 +71,395 @@ export interface BackupError {
}
export interface BackupResult {
// Distinct files in scope this run.
totalFiles: number;
// Originals fetched (or copied from the cache) this run.
downloaded: number;
// Originals already present 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
// downloaded and failed if its bytes landed but its symlink did not.
failed: number;
// This run's per-file errors, in encounter order.
errors: BackupError[];
}
export type ProgressCallback = (message: string) => void;
// The slice of the library that backup drives. `Library` implements it; a test
// can drive backup with a stand-in.
export interface BackupLibrary {
refresh(): Promise<void>;
listCollections(): Collection[];
listFiles(collectionID: number): EnteFile[];
// Get an original's bytes onto disk through the content cache/pools,
// returning where they landed (the cache, or a prior backup).
original(fileID: number): Promise<{ path: string }>;
thumbnail(fileID: number): Promise<{ path: string }>;
}
const sanitizePath = (name: string): string =>
name.replace(/[/\\:*?"<>|]/g, "_").replace(/^\.+/, "_");
type FailureClass = "transient" | "permanent" | "unknown";
const originalFileName = (file: EnteFile): string => {
const ext = extname(file.metadata.title || "") || ".bin";
return `${file.id}${ext}`;
};
interface FailureEntry {
fileID: number;
title: string;
classification: FailureClass;
attempts: number;
lastTriedAt: number;
error: string;
}
export const runBackup = async (
client: Client,
outDir: string,
onProgress?: ProgressCallback,
): Promise<BackupResult> => {
const log = onProgress ?? (() => {});
const LEDGER_VERSION = 1;
mkdirSync(outDir, { recursive: true });
const originalsDir = join(outDir, "originals");
mkdirSync(originalsDir, { recursive: true });
const collectionsDir = join(outDir, "collections");
mkdirSync(collectionsDir, { recursive: true });
// The originals/ filename for a file: `<id><ext>`, the extension taken from the
// title (or `.bin`). Matches the content cache's own naming so a present check
// lines up with what a fetch would write.
const originalName = (file: EnteFile): string =>
`${file.id}${safeExtension(file.metadata.title)}`;
log("Fetching collections...");
const collections = await client.listCollections();
const downloadedIDs = new Set<number>();
let totalFiles = 0;
let downloaded = 0;
let skipped = 0;
let failed = 0;
const errors: BackupError[] = [];
for (const col of collections) {
const colDirName = sanitizePath(col.name || `collection-${col.id}`);
const colDir = join(collectionsDir, colDirName);
mkdirSync(colDir, { recursive: true });
log(`[${col.name}] Fetching file list...`);
const files = await client.listFiles(col.id, col.key);
log(`[${col.name}] ${files.length} file(s)`);
const collectionMeta: {
id: number;
name: string;
type: string;
files: { id: number; metadata: EnteFile["metadata"] }[];
} = {
id: col.id,
name: col.name,
type: col.type,
files: [],
};
for (const file of files) {
totalFiles++;
const origName = originalFileName(file);
const origPath = join(originalsDir, origName);
const linkName = sanitizePath(
file.metadata.title || `file-${file.id}`,
);
const linkPath = join(colDir, linkName);
if (!downloadedIDs.has(file.id)) {
if (existsSync(origPath) && statSync(origPath).size > 0) {
skipped++;
downloadedIDs.add(file.id);
} else {
// A regular file with content is treated as complete. A zero-byte file is not:
// it is the shape an aborted write leaves and must be re-fetched.
const isPresent = (path: string): boolean => {
try {
log(`[${col.name}] Downloading ${linkName}...`);
await client.downloadFile(file, origPath);
downloaded++;
downloadedIDs.add(file.id);
} catch (err) {
log(
`[${col.name}] FAILED ${linkName}: ${err instanceof Error ? err.message : err}`,
);
failed++;
errors.push({
fileID: file.id,
title: file.metadata.title,
collection: col.name,
error:
err instanceof Error
? err.message
: String(err),
});
continue;
}
}
const s = statSync(path);
return s.isFile() && s.size > 0;
} catch {
return false;
}
};
// Write per-file metadata JSON alongside the original
const metaJsonPath = join(originalsDir, `${file.id}.json`);
if (!existsSync(metaJsonPath)) {
const fileMeta: Record<string, unknown> = {
// 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.
const classify = (err: unknown): FailureClass => {
const e = err as NodeJS.ErrnoException;
const text =
`${e?.code ?? ""} ${err instanceof Error ? err.message : String(err)}`.toLowerCase();
if (
/timeout|timed out|econnreset|econnrefused|econnaborted|network|socket|eai_again|throttl|temporarily|429|500|502|503|504/.test(
text,
)
) {
return "transient";
}
if (
/enoent|eacces|eperm|eexist|eisdir|enotempty|erofs|enospc|not found|forbidden|unauthor|decrypt|truncat|401|403|404/.test(
text,
)
) {
return "permanent";
}
return "unknown";
};
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 });
}
};
// A process-ID check: signal 0 delivers nothing and only reports whether the
// process exists. EPERM means it exists but belongs to another user.
const isRunning = (pid: number): boolean => {
try {
process.kill(pid, 0);
return true;
} catch (err) {
return (err as NodeJS.ErrnoException).code === "EPERM";
}
};
// Delete the temp files `copyAtomic` leaves behind when a backup is killed
// before its rename. Only files whose process is no longer running are
// removed, so a backup running at the same time keeps its own. A reused
// process ID can only keep a leftover a while longer, never remove a live one.
const removeLeftoverTempFiles = (dir: string): void => {
let names: string[];
try {
names = readdirSync(dir);
} catch {
return;
}
for (const name of names) {
const match = /^\.quak-backup-.*-(\d+)-[0-9a-z]*\.tmp$/.exec(name);
if (match && !isRunning(Number(match[1]))) {
rmSync(join(dir, name), { force: true });
}
}
};
// 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.
const rebuildSymlink = (linkPath: string, target: string): void => {
try {
const st = lstatSync(linkPath);
if (st.isSymbolicLink() && readlinkSync(linkPath) === target) return;
} catch {
// Nothing there (or unreadable): fall through to create it.
}
// Remove a wrong symlink or stray file. `force` ignores a missing path but
// still refuses a directory (no `recursive`), which surfaces as a failure.
rmSync(linkPath, { force: true });
symlinkSync(target, linkPath);
};
const loadLedger = (path: string): Map<number, FailureEntry> => {
const ledger = new Map<number, FailureEntry>();
try {
const parsed = JSON.parse(readFileSync(path, "utf-8")) as {
files?: Record<string, FailureEntry>;
};
for (const entry of Object.values(parsed.files ?? {})) {
if (entry && typeof entry.fileID === "number") {
ledger.set(entry.fileID, entry);
}
}
} catch {
// No ledger yet, or an unreadable one: start clean.
}
return ledger;
};
const saveLedger = (path: string, ledger: Map<number, FailureEntry>): void => {
if (ledger.size === 0) {
rmSync(path, { force: true });
return;
}
const files: Record<string, FailureEntry> = {};
for (const [fileID, entry] of ledger) files[String(fileID)] = entry;
writeFileSync(
path,
JSON.stringify({ version: LEDGER_VERSION, files }, null, 2),
);
};
const writeSidecar = (path: string, file: EnteFile): void => {
const meta: Record<string, unknown> = {
id: file.id,
collectionID: file.collectionID,
ownerID: file.ownerID,
metadata: file.metadata,
};
if (file.magicMetadata) {
fileMeta.magicMetadata = file.magicMetadata;
if (file.magicMetadata) meta.magicMetadata = file.magicMetadata;
if (file.pubMagicMetadata) meta.pubMagicMetadata = file.pubMagicMetadata;
writeFileSync(path, JSON.stringify(meta, null, 2));
};
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 (file.pubMagicMetadata) {
fileMeta.pubMagicMetadata = file.pubMagicMetadata;
}
writeFileSync(metaJsonPath, JSON.stringify(fileMeta, null, 2));
const includeOriginals = opts.includeOriginals ?? true;
const includeThumbnails = opts.includeThumbnails ?? false;
const log = opts.onProgress ?? (() => {});
const only = opts.onlyAlbumNames ? new Set(opts.onlyAlbumNames) : undefined;
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);
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).
const collections = lib
.listCollections()
.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);
const distinct = new Map<number, EnteFile>();
const filesByCollection = new Map<number, EnteFile[]>();
for (const c of collections) {
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);
}
if (!existsSync(linkPath) && existsSync(origPath)) {
const target = relative(colDir, origPath);
symlinkSync(target, linkPath);
}
const errors: BackupError[] = [];
const failedThisRun = new Set<number>();
let downloaded = 0;
let skipped = 0;
collectionMeta.files.push({
id: file.id,
metadata: file.metadata,
const recordFailure = (
file: EnteFile,
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;
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,
classification: classify(err),
attempts: (prior?.attempts ?? 0) + 1,
lastTriedAt: now,
error,
});
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.
if (includeOriginals) {
for (const [fileID, file] of distinct) {
const dest = join(originalsDir, originalName(file));
if (isPresent(dest)) {
skipped++;
continue;
}
try {
log(`Fetching original ${file.metadata.title} (${fileID})...`);
const { path } = await lib.original(fileID);
await copyAtomic(path, dest);
downloaded++;
} catch (err) {
log(
`FAILED original ${file.metadata.title}: ${errorMessage(err)}`,
);
recordFailure(
file,
collectionName.get(file.collectionID) ?? "",
err,
);
}
}
}
if (includeThumbnails) {
for (const [fileID, file] of distinct) {
const dest = join(thumbnailsDir, `${fileID}.jpg`);
if (isPresent(dest)) continue;
try {
const { path } = await lib.thumbnail(fileID);
await copyAtomic(path, dest);
} catch (err) {
recordFailure(
file,
collectionName.get(file.collectionID) ?? "",
err,
);
}
}
}
// 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) {
const orig = join(originalsDir, originalName(file));
if (isPresent(orig)) {
writeSidecar(join(originalsDir, `${fileID}.json`), file);
}
}
}
// Then the per-collection symlink trees and JSON.
for (const c of collections) {
const colDirName = sanitizeFileName(c.name, `collection-${c.id}`);
const colDir = join(collectionsDir, colDirName);
mkdirSync(colDir, { recursive: true });
const files = filesByCollection.get(c.id) ?? [];
const metaFiles: { id: number; metadata: EnteFile["metadata"] }[] = [];
for (const file of files) {
metaFiles.push({ id: file.id, metadata: file.metadata });
if (!includeOriginals) continue;
const orig = join(originalsDir, originalName(file));
if (!isPresent(orig)) continue;
const linkName = sanitizeFileName(
file.metadata.title,
`file-${file.id}`,
);
const linkPath = join(colDir, linkName);
try {
rebuildSymlink(linkPath, relative(colDir, orig));
} catch (err) {
log(
`FAILED symlink ${c.name}/${linkName}: ${errorMessage(err)}`,
);
recordFailure(file, c.name, err);
}
}
writeFileSync(
join(collectionsDir, `${colDirName}.json`),
JSON.stringify(collectionMeta, null, 2),
JSON.stringify(
{ id: c.id, name: c.name, type: c.type, files: metaFiles },
null,
2,
),
);
}
return { totalFiles, downloaded, skipped, failed, errors };
// Reconcile the ledger against what this run actually attempted: an entry
// survives only for a file that failed this run. A file that succeeded had
// 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.
for (const fileID of [...ledger.keys()]) {
if (!failedThisRun.has(fileID)) ledger.delete(fileID);
}
saveLedger(ledgerPath, ledger);
return {
totalFiles: distinct.size,
downloaded,
skipped,
failed: ledger.size,
errors,
};
};
+486
View File
@@ -0,0 +1,486 @@
// The CLI's commands as plain functions.
//
// Each command takes its options and a `CliContext` and resolves to the exit
// code; a thrown error is left to the caller. Nothing here calls
// `process.exit`: `bin/quak.ts` wires these to the command line and exits with
// the returned code once output has drained. Output must stay byte-identical
// (see `cli-output.ts`).
import { input, password as passwordPrompt } from "@inquirer/prompts";
import {
copyFileSync,
existsSync,
mkdirSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { Client, type ClientSnapshot } from "./client.js";
import { init } from "./crypto/index.js";
import { Library, type LibraryClient } from "./library/index.js";
import {
fileListRow,
fileListLine,
originalName,
thumbnailName,
} from "./cli-output.js";
import { freshCollections, freshFiles, freshFile } from "./cli-read.js";
import { runMetadataBackup } from "./metadata-backup.js";
import { listMissingThumbnails, fixMissingThumbnails } from "./thumbnails.js";
export interface CliContext {
stdout: { write(text: string): unknown };
stderr: { write(text: string): unknown };
// Directory holding `session.json`.
sessionDir: string;
// The `--cache-dir` global, or undefined to let the library pick its
// per-user default keyed by the account id.
cacheDir?: string;
// Reads the session file into a client, or null when there is none. The
// CLI passes `loadSession` from `cli-session.ts`; tests pass a fake client.
loadSession: (path: string) => Client | null;
}
const sessionPath = (ctx: CliContext): string =>
join(ctx.sessionDir, "session.json");
// Write the session readable by its owner only, in a directory only its owner
// can enter.
export const saveSession = (
sessionDir: string,
snapshot: ClientSnapshot,
): void => {
mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
writeFileSync(
join(sessionDir, "session.json"),
JSON.stringify(snapshot, null, 2),
{ mode: 0o600 },
);
};
// The saved client, or undefined after telling the user why there is none.
const requireSession = (ctx: CliContext): Client | undefined => {
let client: Client | null;
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`,
);
return undefined;
}
if (!client) {
ctx.stderr.write(
`Not logged in. Run "quak login" first.\nSession file: ${sessionPath(ctx)}\n`,
);
return undefined;
}
return client;
};
// 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
// own terms. The content source is kept so `get`/`get-thumb`/`--exif` can fetch
// originals through the on-disk cache.
const readLibraryClient = (client: Client): LibraryClient => ({
whoami: () => client.whoami(),
collectionsSince: (args) => client.collectionsSince(args),
filesSince: (args) => client.filesSince(args),
contentSource: () => client.contentSource(),
});
// 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> =>
Library.open({
client: readLibraryClient(client),
cacheDirectory: ctx.cacheDir,
refreshIntervalSeconds: 3600,
precacheThumbnails: false,
precacheOriginals: false,
});
const prompt = async (message: string): Promise<string> => input({ message });
const promptSecret = async (message: string): Promise<string> =>
passwordPrompt({ message, mask: true });
export const loginCommand = async (ctx: CliContext): Promise<number> => {
await init();
const email = process.env.QUAK_EMAIL ?? (await prompt("Email"));
const password =
process.env.QUAK_PASSWORD ?? (await promptSecret("Password"));
ctx.stderr.write("Authenticating...\n");
try {
const client = await Client.login({
email,
password,
totp: async () => prompt("TOTP code: "),
emailOTP: async () => prompt("Email verification code: "),
});
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`);
} catch (err) {
ctx.stderr.write(
`Login failed: ${err instanceof Error ? err.message : err}\n`,
);
return 1;
}
return 0;
};
export const whoamiCommand = async (ctx: CliContext): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 1;
const info = client.whoami();
ctx.stdout.write(JSON.stringify(info) + "\n");
return 0;
};
export const logoutCommand = async (ctx: CliContext): Promise<number> => {
if (existsSync(sessionPath(ctx))) {
unlinkSync(sessionPath(ctx));
ctx.stderr.write("Session deleted.\n");
} else {
ctx.stderr.write("No session found.\n");
}
return 0;
};
export const collectionsCommand = async (
ctx: CliContext,
opts: { json?: boolean },
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 1;
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.
const collections = await freshCollections(lib);
if (opts.json) {
ctx.stdout.write(
JSON.stringify(
collections.map((c) => ({
id: c.id,
name: c.name,
type: c.type,
ownerID: c.ownerID,
isShared: c.isShared,
updationTime: c.updationTime,
})),
null,
2,
) + "\n",
);
} else {
for (const c of collections) {
ctx.stdout.write(
`${c.id}\t${c.type}\t${c.name}${c.isShared ? " (shared)" : ""}\n`,
);
}
}
return 0;
} finally {
await lib.close();
}
};
export const filesCommand = async (
ctx: CliContext,
opts: { collection: string; json?: boolean },
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 1;
const collectionID = Number(opts.collection);
if (!Number.isFinite(collectionID)) {
ctx.stderr.write("Invalid collection ID\n");
return 1;
}
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
// metadata (raw title, microsecond creationTime) via cli-output, and in
// the pre-library CLI's enumeration order, not the projection's
// newest-first order.
const files = await freshFiles(lib, collectionID);
if (!files) {
ctx.stderr.write(`Collection ${collectionID} not found\n`);
return 1;
}
if (opts.json) {
ctx.stdout.write(
JSON.stringify(files.map(fileListRow), null, 2) + "\n",
);
} else {
for (const file of files) {
ctx.stdout.write(fileListLine(file) + "\n");
}
}
return 0;
} finally {
await lib.close();
}
};
export const getCommand = async (
ctx: CliContext,
fileIDStr: string,
opts: { out?: string },
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 1;
const fileID = Number(fileIDStr);
if (!Number.isFinite(fileID)) {
ctx.stderr.write("Invalid file ID\n");
return 1;
}
const lib = await openReadLibrary(ctx, client);
try {
// Force a server round-trip so the file resolves against current state
// (issue #36 amendment, issue #52).
const resolved = await freshFile(lib, fileID);
if (!resolved) {
ctx.stderr.write(`File ${fileID} not found\n`);
return 1;
}
const { photo, file } = resolved;
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);
copyFileSync(result.path, outPath);
ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
return 0;
} finally {
await lib.close();
}
};
export const getThumbCommand = async (
ctx: CliContext,
fileIDStr: string,
opts: { out?: string },
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 1;
const fileID = Number(fileIDStr);
if (!Number.isFinite(fileID)) {
ctx.stderr.write("Invalid file ID\n");
return 1;
}
const lib = await openReadLibrary(ctx, client);
try {
// Force a server round-trip so the file resolves against current state
// (issue #36 amendment, issue #52).
const resolved = await freshFile(lib, fileID);
if (!resolved) {
ctx.stderr.write(`File ${fileID} not found\n`);
return 1;
}
const { photo, file } = resolved;
const result = await photo.thumbnail();
// Default name is thumb_<file's own title>, as the pre-library CLI
// 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`);
return 0;
} finally {
await lib.close();
}
};
export const backupMetadataCommand = async (
ctx: CliContext,
dir: string,
opts: { exif?: boolean; all?: boolean },
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 1;
const lib = await openReadLibrary(ctx, client);
try {
await runMetadataBackup(lib, client, dir, {
exif: opts.exif || opts.all,
onProgress: (msg) => ctx.stderr.write(msg + "\n"),
});
return 0;
} finally {
await lib.close();
}
};
export const backupCommand = async (
ctx: CliContext,
dir: string,
opts: { json?: boolean },
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 1;
ctx.stderr.write("Starting backup...\n");
const lib = await Library.open({
client,
downloadDirectory: dir,
cacheDirectory: ctx.cacheDir,
});
try {
const result = await lib.backup({
downloadDirectory: dir,
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`,
);
}
}
}
return result.failed > 0 ? 1 : 0;
} finally {
await lib.close();
}
};
export const listMissingThumbnailsCommand = async (
ctx: CliContext,
opts: { json?: boolean },
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 1;
const lib = await openReadLibrary(ctx, client);
try {
const missing = await listMissingThumbnails(lib, client, (msg) => {
if (!opts.json) ctx.stderr.write(msg + "\n");
});
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");
} else {
ctx.stderr.write(
`\n${missing.length} file(s) with missing thumbnails:\n`,
);
for (const m of missing) {
ctx.stdout.write(
`${m.fileID}\t${m.title}\t${m.collection}\t${m.reason}\n`,
);
}
}
}
return 0;
} finally {
await lib.close();
}
};
export const fixMissingThumbnailsCommand = async (
ctx: CliContext,
opts: { file?: string[]; json?: boolean },
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 1;
const lib = await openReadLibrary(ctx, client);
try {
let fileIDs: number[];
if (opts.file && opts.file.length > 0) {
fileIDs = opts.file.map(Number).filter(Number.isFinite);
} else {
ctx.stderr.write("Scanning for missing thumbnails...\n");
const missing = await listMissingThumbnails(lib, client, (msg) => {
if (!opts.json) ctx.stderr.write(msg + "\n");
});
fileIDs = missing.map((m) => m.fileID);
if (fileIDs.length === 0) {
ctx.stderr.write("No missing thumbnails found.\n");
return 0;
}
ctx.stderr.write(`Found ${fileIDs.length} file(s) to fix.\n`);
}
const results = await fixMissingThumbnails(
lib,
client,
fileIDs,
(msg) => {
if (!opts.json) ctx.stderr.write(msg + "\n");
},
);
if (opts.json) {
ctx.stdout.write(JSON.stringify(results, null, 2) + "\n");
} else {
const fixed = results.filter((r) => r.status === "fixed").length;
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`);
if (skipped > 0) {
ctx.stderr.write("\nSkipped (unsupported format):\n");
for (const r of results.filter((r) => r.status === "skipped")) {
ctx.stderr.write(
` ${r.fileID}\t${r.title}\t${r.reason}\n`,
);
}
}
if (failed > 0) {
ctx.stderr.write("\nFailed files:\n");
for (const r of results.filter((r) => r.status === "failed")) {
ctx.stderr.write(
` ${r.fileID}\t${r.title}\t${r.reason}\n`,
);
}
}
}
return results.some((r) => r.status === "failed") ? 1 : 0;
} finally {
await lib.close();
}
};
+43
View File
@@ -0,0 +1,43 @@
// How the CLI presents a file's identity in `files`, `get`, and `get-thumb`.
//
// These read the file's own decrypted metadata — the raw title and the
// creationTime in microseconds — rather than the `PhotoRecord` projection the
// rest of the library exposes. The projection prefers `editedName`/`editedTime`
// and reports time in milliseconds, which is right for a photo browser but
// would change the CLI's externally-visible output. The pre-library CLI printed
// `metadata.title` and `metadata.creationTime` and named downloads after
// `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 type { EnteFile, FileType, Microseconds } from "./model/types.js";
// One row of `quak files --json`.
export interface FileListRow {
id: number;
title: string;
fileType: FileType;
creationTime: Microseconds;
collectionID: number;
}
export const fileListRow = (file: EnteFile): FileListRow => ({
id: file.id,
title: file.metadata.title,
fileType: file.metadata.fileType,
creationTime: file.metadata.creationTime,
collectionID: file.collectionID,
});
// One line of `quak files` in its human, tab-separated form.
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-thumb` when `--out` is not given.
export const thumbnailName = (file: EnteFile): string =>
`thumb_${originalName(file)}`;
+62
View File
@@ -0,0 +1,62 @@
// How the CLI's read commands obtain current data.
//
// `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.
//
// `collections` and `files` also list in the library's own enumeration order —
// `listCollections()`/`listFiles()`, the order 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`.
import type { Collection, EnteFile } from "./model/types.js";
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.
export interface FreshReadLibrary {
fresh(): Promise<unknown>;
listCollections(): 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 (
lib: FreshReadLibrary,
): Promise<Collection[]> => {
await lib.fresh();
return lib.listCollections();
};
// 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".
export const freshFiles = async (
lib: FreshReadLibrary,
collectionID: number,
): Promise<EnteFile[] | undefined> => {
await lib.fresh();
if (!lib.getCollection(collectionID)) return undefined;
return lib.listFiles(collectionID);
};
// One file, current as of a forced refresh, resolved to both its content
// handle (`Photo`, for fetching bytes) and its raw record (`EnteFile`, for the
// default output name and field values). `undefined` when the file is unknown.
export const freshFile = async (
lib: FreshReadLibrary,
fileID: number,
): Promise<{ photo: Photo; file: EnteFile } | undefined> => {
await lib.fresh();
const photo = lib.photos.byID({ fileID });
const file = lib.getFileByID(fileID);
if (!photo || !file) return undefined;
return { photo, file };
};
+26
View File
@@ -0,0 +1,26 @@
// How the CLI reads its saved session file back into a `Client`.
//
// A missing file means "not logged in" and returns null. A file that exists but
// cannot be read back into a client (bad JSON, a missing field, a key of the
// wrong length) throws an error saying the session file is corrupt, so the CLI
// can tell the user which of the two it is. Needs `init()` first.
import { existsSync, readFileSync } from "node:fs";
import type { ApiClientOptions } from "./api/client.js";
import { Client } from "./client.js";
export const loadSession = (
path: string,
apiOptions?: ApiClientOptions,
): Client | null => {
if (!existsSync(path)) return null;
try {
return Client.fromJSON(
JSON.parse(readFileSync(path, "utf-8")),
apiOptions,
);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new Error(`Session file ${path} is corrupt: ${reason}`);
}
};
+183 -32
View File
@@ -7,11 +7,16 @@ import {
} from "./auth/login.js";
import { unwrapAuth } from "./auth/unwrap.js";
import { init, fromBase64, toBase64 } from "./crypto/index.js";
import { fetchMLDataBatch, type MLData } from "./mldata-fetch.js";
import { decryptCollection, decryptFile } from "./model/index.js";
import {
downloadFile as dlFile,
downloadThumbnail as dlThumb,
} from "./download/index.js";
import {
makeDownloadContentSource,
type ContentSource,
} from "./library/content.js";
import type {
Collection,
EnteFile,
@@ -37,6 +42,22 @@ export interface ClientSnapshot {
publicKey: string;
}
// 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.
export interface CollectionsPage {
collections: Collection[];
deleted: number[];
cursor: number;
}
export interface FilesPage {
files: EnteFile[];
deleted: number[];
cursor: number;
}
export class Client {
private readonly api: ApiClient;
private readonly email: string;
@@ -104,18 +125,57 @@ export class Client {
);
}
static fromJSON(
snapshot: ClientSnapshot,
apiOptions?: ApiClientOptions,
): Client {
const api = new ApiClient({ ...apiOptions, authToken: snapshot.token });
// Restore a client from a `toJSON()` snapshot. The snapshot usually comes
// straight from `JSON.parse` of a file on disk, so every field is checked
// before use; a bad one throws an error naming it. Needs `init()` first.
static fromJSON(snapshot: unknown, apiOptions?: ApiClientOptions): Client {
const invalid = (field: string, problem: string): Error =>
new Error(`Invalid session data: ${field} ${problem}`);
if (typeof snapshot !== "object" || snapshot === null) {
throw new Error("Invalid session data: not a JSON object");
}
const s = snapshot as Record<string, unknown>;
for (const field of ["email", "token"]) {
if (typeof s[field] !== "string" || s[field] === "") {
throw invalid(field, "must be a non-empty string");
}
}
if (!Number.isInteger(s.userID)) {
throw invalid("userID", "must be an integer");
}
const key = (field: string): Uint8Array => {
const value = s[field];
if (typeof value !== "string") {
throw invalid(field, "must be a base64 string");
}
let bytes: Uint8Array;
try {
bytes = fromBase64(value);
} catch {
throw invalid(field, "is not valid base64");
}
// The master key (secretbox) and the key pair (box) are all 32 bytes.
if (bytes.length !== 32) {
throw invalid(
field,
`must decode to 32 bytes, got ${bytes.length}`,
);
}
return bytes;
};
const api = new ApiClient({
...apiOptions,
authToken: s.token as string,
});
return new Client(
api,
snapshot.email,
snapshot.userID,
fromBase64(snapshot.masterKey),
fromBase64(snapshot.secretKey),
fromBase64(snapshot.publicKey),
s.email as string,
s.userID as number,
key("masterKey"),
key("secretKey"),
key("publicKey"),
);
}
@@ -124,6 +184,14 @@ export class Client {
return this.api;
}
// The content-cache byte source over this client's API: each fetch is the
// download layer's request + streaming decrypt + atomic write. `Library`
// calls this to enable the on-disk content cache.
contentSource(): ContentSource {
this.assertLoggedIn();
return makeDownloadContentSource(this.api);
}
private assertLoggedIn(): void {
if (this.loggedOut) throw new Error("Client has been logged out");
}
@@ -135,31 +203,55 @@ export class Client {
toJSON(): ClientSnapshot {
this.assertLoggedIn();
const token = this.api.getAuthToken();
if (!token) {
throw new Error("Cannot serialize client: it has no auth token");
}
return {
email: this.email,
userID: this.userID,
token: this.api["token"]!,
token,
masterKey: toBase64(this.masterKey),
secretKey: toBase64(this.secretKey),
publicKey: toBase64(this.publicKey),
};
}
// Zeroes the key buffers in place, so any copy of the reference held
// elsewhere is wiped too. Every method checks `assertLoggedIn` before
// touching the keys, so nothing decrypts with the zeroed keys.
logout(): void {
this.loggedOut = true;
this.api.clearAuthToken();
this.masterKey.fill(0);
this.secretKey.fill(0);
this.publicKey.fill(0);
}
async listCollections(): Promise<Collection[]> {
// 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.
async collectionsSince(args: {
sinceTime: number;
}): Promise<CollectionsPage> {
this.assertLoggedIn();
const { collections } = await this.api.getJSON<{
const { collections: raws } = await this.api.getJSON<{
collections: RawCollection[];
}>("/collections/v2", { sinceTime: 0 });
// The sync API keeps returning deleted collections as tombstones
// (isDeleted: true); their diff endpoint 404s, so drop them.
return collections
.filter((raw) => !raw.isDeleted)
.map((raw) =>
}>("/collections/v2", { sinceTime: args.sinceTime });
// logout() may have zeroed the keys while the request was in flight.
this.assertLoggedIn();
const collections: Collection[] = [];
const deleted: number[] = [];
let cursor = args.sinceTime;
for (const raw of raws) {
if (raw.isDeleted) {
deleted.push(raw.id);
} else {
collections.push(
decryptCollection(
raw,
{
@@ -171,31 +263,90 @@ export class Client {
),
);
}
if (raw.updationTime > cursor) cursor = raw.updationTime;
}
return { collections, deleted, cursor };
}
async listFiles(
collectionID: number,
collectionKey: Uint8Array,
): Promise<EnteFile[]> {
// 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.
async filesSince(args: {
collectionID: number;
collectionKey: Uint8Array;
sinceTime: number;
}): Promise<FilesPage> {
this.assertLoggedIn();
const allFiles: EnteFile[] = [];
let sinceTime = 0;
const { collectionID, collectionKey } = args;
const files: EnteFile[] = [];
const deleted: number[] = [];
let cursor = args.sinceTime;
for (;;) {
const { diff, hasMore } = await this.api.getJSON<{
diff: RawEnteFile[];
hasMore: boolean;
}>("/collections/v2/diff", { collectionID, sinceTime });
}>("/collections/v2/diff", { collectionID, sinceTime: cursor });
let pageMax = cursor;
for (const raw of diff) {
if (!raw.isDeleted) {
allFiles.push(decryptFile(raw, collectionKey));
if (raw.isDeleted) {
deleted.push(raw.id);
} else {
files.push(decryptFile(raw, collectionKey));
}
if (raw.updationTime > sinceTime) {
sinceTime = raw.updationTime;
if (raw.updationTime > pageMax) pageMax = raw.updationTime;
}
if (!hasMore) {
cursor = pageMax;
break;
}
if (!hasMore) break;
// The server says there is more, but this page did not advance the
// cursor: following hasMore would refetch the same page forever
// (#7). Stop with a clear error instead of looping.
if (pageMax <= cursor) {
throw new Error(
`/collections/v2/diff for collection ${collectionID} ` +
`returned hasMore with a cursor that did not advance ` +
`(stuck at ${cursor}); refusing to loop`,
);
}
return allFiles;
cursor = pageMax;
}
return { files, deleted, cursor };
}
// Whole-account listing: every live collection, 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.
async listFiles(
collectionID: number,
collectionKey: Uint8Array,
): Promise<EnteFile[]> {
const { files } = await this.filesSince({
collectionID,
collectionKey,
sinceTime: 0,
});
return files;
}
// 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.
async fetchMLData(args: {
fileIDs: number[];
fileKeys: Map<number, Uint8Array>;
}): Promise<Map<number, MLData>> {
this.assertLoggedIn();
return fetchMLDataBatch(this.api, args.fileIDs, args.fileKeys);
}
async downloadFile(
+219 -62
View File
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { rename, rm, writeFile } from "node:fs/promises";
import { open, rename, rm } from "node:fs/promises";
import type { FileHandle } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
fromBase64,
@@ -10,6 +11,7 @@ import {
streamTagFinal,
} from "../crypto/index.js";
import { TruncatedStreamError } from "../errors.js";
import { sanitizeFileName } from "../filename.js";
import { withRetry } from "../retry.js";
import type { ApiClient } from "../api/client.js";
import type { EnteFile } from "../model/types.js";
@@ -19,50 +21,114 @@ export interface DownloadResult {
bytesWritten: number;
}
// Fired as decrypted plaintext accumulates, with the running total of
// plaintext bytes recovered so far. Within one download it is non-decreasing
// and its last value equals the final `bytesWritten`. A retry restarts the
// file from byte zero (see `fetchAndDecrypt`), so a fresh attempt begins its
// own count from zero.
export type ProgressCallback = (bytesDone: number) => void;
const ENC_CHUNK_SIZE = STREAM_CHUNK_SIZE + STREAM_CHUNK_OVERHEAD;
// Decrypt a secretstream body, handing each plaintext chunk to `sink` as it is
// produced rather than accumulating the whole file. Peak memory is one
// ciphertext chunk of network buffer plus one plaintext chunk — bounded by
// `STREAM_CHUNK_SIZE` regardless of the file's size — so a multi-gigabyte video
// no longer needs its size again in RAM. Returns the total plaintext length.
//
// The truncation contract is exactly the buffered version's, only the sink is
// new: a body cut short still decrypts and authenticates up to its last whole
// chunk, so the absence of TAG_FINAL is the sole evidence it was cut short, and
// this throws rather than let a caller keep a short file. The sink has already
// seen those chunks by then; the caller (`decryptToTemp`) stages them in a temp
// file that is renamed into place only on a clean return, so a throw leaves
// nothing on disk.
const streamDecrypt = async (
stream: ReadableStream<Uint8Array>,
header: Uint8Array,
key: Uint8Array,
): Promise<Uint8Array> => {
sink: (plaintext: Uint8Array) => Promise<void>,
onProgress?: ProgressCallback,
): Promise<number> => {
const state = initStreamPull(header, key);
const reader = stream.getReader();
let buffer = new Uint8Array(0);
const plainChunks: Uint8Array[] = [];
// Incoming reads are held as-is and only stitched into a contiguous chunk
// at each `ENC_CHUNK_SIZE` boundary, so every received byte is copied once.
// Concatenating on each read instead — reallocating the whole accumulator
// per read — is O(n^2) in the bytes buffered, and for a 4 MiB chunk that
// memory churn dwarfs the libsodium decryption itself.
const pending: Uint8Array[] = [];
let pendingBytes = 0;
let totalPlain = 0;
let chunksPulled = 0;
let lastTag = -1;
for (;;) {
const { done, value } = await reader.read();
if (value) {
const merged = new Uint8Array(buffer.length + value.length);
merged.set(buffer);
merged.set(value, buffer.length);
buffer = merged;
// Remove the first `size` bytes from `pending` as one contiguous buffer.
// A read that straddles the boundary is split with `subarray` (a view, no
// copy); its tail stays queued for the next chunk. `size` never exceeds
// `pendingBytes`, so the queue always holds enough.
const takeContiguous = (size: number): Uint8Array => {
const out = new Uint8Array(size);
let offset = 0;
while (offset < size) {
const piece = pending[0]!;
const need = size - offset;
if (piece.length <= need) {
out.set(piece, offset);
offset += piece.length;
pending.shift();
} else {
out.set(piece.subarray(0, need), offset);
pending[0] = piece.subarray(need);
offset += need;
}
}
pendingBytes -= size;
return out;
};
while (buffer.length >= ENC_CHUNK_SIZE) {
const encChunk = buffer.slice(0, ENC_CHUNK_SIZE);
buffer = buffer.slice(ENC_CHUNK_SIZE);
const { plaintext, tag } = pullStreamChunk(state, encChunk);
plainChunks.push(plaintext);
const consume = async (
plaintext: Uint8Array,
tag: number,
): Promise<void> => {
await sink(plaintext);
totalPlain += plaintext.length;
chunksPulled++;
lastTag = tag;
onProgress?.(totalPlain);
};
try {
for (;;) {
const { done, value } = await reader.read();
if (value && value.length > 0) {
pending.push(value);
pendingBytes += value.length;
}
while (pendingBytes >= ENC_CHUNK_SIZE) {
const encChunk = takeContiguous(ENC_CHUNK_SIZE);
// A whole chunk that fails to authenticate while the stream
// carries on is corruption, not truncation; that error
// propagates unchanged.
const { plaintext, tag } = pullStreamChunk(state, encChunk);
await consume(plaintext, tag);
}
if (done) {
if (buffer.length > 0) {
if (pendingBytes > 0) {
const buffer = takeContiguous(pendingBytes);
// Whatever is left over once every whole chunk has been
// consumed must be the stream's final chunk, and a final
// chunk that actually arrived in full authenticates. If it
// does not, the body stopped part-way through a chunk — the
// ordinary shape of a dropped connection. Poly1305 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.
// chunk that actually arrived in full authenticates. If
// it does not, the body stopped part-way through a chunk
// — the ordinary shape of a dropped connection. Poly1305
// 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
// truncation.
let pulled;
try {
pulled = pullStreamChunk(state, buffer);
@@ -72,20 +138,18 @@ const streamDecrypt = async (
{ cause: err },
);
}
plainChunks.push(pulled.plaintext);
totalPlain += pulled.plaintext.length;
chunksPulled++;
lastTag = pulled.tag;
await consume(pulled.plaintext, pulled.tag);
}
break;
}
}
} finally {
reader.releaseLock();
}
// Only the last chunk of a secretstream carries TAG_FINAL. Everything a
// dropped connection did deliver still decrypts and authenticates, so the
// absence of TAG_FINAL is the only evidence that the body was cut short.
// Returning a short plaintext here would put a corrupt file on disk that
// later backup runs would treat as complete.
if (chunksPulled === 0) {
throw new TruncatedStreamError(
"download: stream truncated: response body contained no secretstream chunks",
@@ -97,31 +161,65 @@ const streamDecrypt = async (
`download: stream truncated: last chunk tag ${lastTag}, expected TAG_FINAL (${tagFinal})`,
);
}
const result = new Uint8Array(totalPlain);
let offset = 0;
for (const chunk of plainChunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
return totalPlain;
};
// Write `plaintext` to `destination` atomically: stage it in a temporary
// sibling file (same directory, so the rename cannot cross a filesystem
// boundary) and rename it into place. Callers therefore never observe a
// partially written destination, and a pre-existing file at that path is
// Fsync a file or a directory, so its contents (for a directory, its entries)
// are on stable storage. Exported for the backup tree's copy, which needs the
// same durability as the writer below.
export const fsyncPath = async (path: string): Promise<void> => {
const handle = await open(path, "r");
try {
await handle.sync();
} finally {
await handle.close();
}
};
// Stage a write to `destination` atomically and durably, then rename it into
// place. `fill` writes the contents into the open temp file handle — either the
// whole buffer at once (`writeAtomic`) or chunk by chunk as they decrypt
// (`decryptToTemp`). The temp file is a sibling of the destination (same
// directory, so the rename cannot cross a filesystem boundary), so callers
// never observe a partially written destination, and a pre-existing file is
// replaced only once the new contents are complete on disk.
const writeAtomic = async (
//
// Durability against a power cut needs two fsyncs. Without them the write can
// return while the data or the rename is still only in the kernel's page
// cache, and a crash then resurrects an empty renamed file — exactly the
// corruption a later backup run treats as a complete download. So the temp
// file's contents are fsynced before the rename, and the containing directory
// is fsynced after it, so both the bytes and the new directory entry are on
// stable storage before this returns.
//
// On any failure — including a `fill` that throws because the stream was
// truncated — the temp file is removed, so the destination is untouched and no
// scratch file is left to fill the disk on repeated failures.
const stageAtomic = async (
destination: string,
plaintext: Uint8Array,
fill: (handle: FileHandle) => Promise<void>,
): Promise<void> => {
const dir = dirname(destination);
// The random suffix keeps concurrent downloads of the same destination
// from stepping on each other's temporary file.
const tmpPath = join(dirname(destination), `.quak-${randomUUID()}.tmp`);
const tmpPath = join(dir, `.quak-${randomUUID()}.tmp`);
try {
await writeFile(tmpPath, plaintext);
const handle = await open(tmpPath, "w");
try {
await fill(handle);
await handle.sync();
} finally {
await handle.close();
}
// `rename` replaces the destination's directory entry rather than
// writing through it: an existing symlink at `destination` is
// replaced, not followed, and the new file has the temp file's
// permissions, not those of the file it replaced.
await rename(tmpPath, destination);
// Fsync the directory so the rename itself survives a crash: renaming
// over a synced temp file still leaves the new directory entry in the
// page cache until the directory is synced.
await fsyncPath(dir);
} catch (err) {
// Best-effort cleanup. A failure to remove the temporary file must
// never replace the error that actually explains what went wrong.
@@ -130,7 +228,54 @@ const writeAtomic = async (
}
};
// Fetch a stream and decrypt it, retrying the whole sequence.
// Write `plaintext` to `destination` atomically and durably. Exported so the
// metadata store can reuse the same durable write for small whole-buffer
// payloads; originals go through `decryptToTemp` instead so they never buffer.
export const writeAtomic = async (
destination: string,
plaintext: Uint8Array,
): Promise<void> =>
stageAtomic(destination, (handle) => handle.writeFile(plaintext));
// Decrypt `stream` straight to `destination`, one plaintext chunk at a time,
// under the atomic writer's temp-then-rename discipline. Memory stays bounded
// by the chunk size: each decrypted chunk is written to the temp file and
// dropped. The rename happens only after the stream authenticates as terminated
// on TAG_FINAL; a truncated stream throws and leaves the destination untouched.
// Returns the plaintext length written.
const decryptToTemp = async (
destination: string,
stream: ReadableStream<Uint8Array>,
header: Uint8Array,
key: Uint8Array,
onProgress?: ProgressCallback,
): Promise<number> => {
let bytesWritten = 0;
try {
await stageAtomic(destination, async (handle) => {
bytesWritten = await streamDecrypt(
stream,
header,
key,
async (plaintext) => {
await handle.write(plaintext);
},
onProgress,
);
});
} catch (err) {
// Cancel the body so its connection is closed now rather than held
// until the stream is garbage collected. A backup run carries on past
// a failed file, so without this every failure would hold a socket.
// This covers every failure, including a temp file that cannot be
// opened and a header that is rejected before the body is read.
await stream.cancel(err).catch(() => undefined);
throw err;
}
return bytesWritten;
};
// Fetch a stream and decrypt it to `destination`, retrying the whole sequence.
//
// The request is only the first third of a download. `getXStream` returns as
// soon as headers arrive, and the bytes are pulled here, so a socket reset
@@ -143,53 +288,65 @@ const writeAtomic = async (
// four attempts would mean sixteen requests for one file. The policy comes
// from the client so a caller that configured one gets it here too.
//
// A retry starts the file over from byte zero: the secretstream pull state is
// not resumable and there is no Range support on these endpoints.
// Because the plaintext is streamed to disk rather than buffered, the atomic
// write is part of the retried unit. A retry starts the file over from byte
// zero — the secretstream pull state is not resumable and there is no Range
// support — staging into a fresh temp file each time: a failed attempt writes
// 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.
const fetchAndDecrypt = async (
api: ApiClient,
openStream: () => Promise<ReadableStream<Uint8Array>>,
header: Uint8Array,
key: Uint8Array,
): Promise<Uint8Array> =>
destination: string,
onProgress?: ProgressCallback,
): Promise<number> =>
withRetry(async () => {
const stream = await openStream();
return streamDecrypt(stream, header, key);
return decryptToTemp(destination, stream, header, key, onProgress);
}, api.getRetryOptions());
export const downloadFile = async (
api: ApiClient,
file: EnteFile,
outPath?: string,
onProgress?: ProgressCallback,
): Promise<DownloadResult> => {
const resolvedPath = outPath ?? file.metadata.title;
// `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.
const resolvedPath =
outPath ?? sanitizeFileName(file.metadata.title, `file-${file.id}`);
const header = fromBase64(file.file.decryptionHeader);
const plaintext = await fetchAndDecrypt(
const bytesWritten = await fetchAndDecrypt(
api,
() => api.getFileStream(file.id, { retry: false }),
header,
file.key,
resolvedPath,
onProgress,
);
// Outside the retry, deliberately: only the attempt that produced a
// complete, authenticated plaintext gets to stage a temporary file, so a
// download that needed three tries still performs exactly one write and
// one rename.
await writeAtomic(resolvedPath, plaintext);
return { path: resolvedPath, bytesWritten: plaintext.length };
return { path: resolvedPath, bytesWritten };
};
export const downloadThumbnail = async (
api: ApiClient,
file: EnteFile,
outPath?: string,
onProgress?: ProgressCallback,
): Promise<DownloadResult> => {
const resolvedPath = outPath ?? `thumb_${file.metadata.title}`;
const resolvedPath =
outPath ??
`thumb_${sanitizeFileName(file.metadata.title, `file-${file.id}`)}`;
const header = fromBase64(file.thumbnail.decryptionHeader);
const plaintext = await fetchAndDecrypt(
const bytesWritten = await fetchAndDecrypt(
api,
() => api.getThumbnailStream(file.id, { retry: false }),
header,
file.key,
resolvedPath,
onProgress,
);
await writeAtomic(resolvedPath, plaintext);
return { path: resolvedPath, bytesWritten: plaintext.length };
return { path: resolvedPath, bytesWritten };
};
+37
View File
@@ -0,0 +1,37 @@
// File names built from server-supplied metadata.
//
// A file's title and a collection's name are decrypted from data the server
// 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.
//
// A path the user typed (`--out`, `outPath`) is not passed through here: the
// caller is trusted, the server is not.
import { extname } from "node:path";
// Path separators, characters Windows forbids in file names, and control
// characters (NUL included).
// eslint-disable-next-line no-control-regex
const UNSAFE_CHARACTERS = /[/\\:*?"<>|\x00-\x1f\x7f]/g;
// Names Windows reserves for devices, with or without an extension.
const RESERVED_DEVICE_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i;
// `name` made safe to use as a single file name. Each unsafe character becomes
// `_`, a leading run of dots becomes one `_`, and a device name gets a leading
// `_`. A name with none of these comes back unchanged. An empty name becomes
// `fallback`, which the caller derives from the record's ID.
export const sanitizeFileName = (name: string, fallback: string): string => {
if (name === "") return fallback;
const cleaned = name.replace(UNSAFE_CHARACTERS, "_").replace(/^\.+/, "_");
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.
export const safeExtension = (title: string): string => {
const ext = extname(title);
return /^\.[A-Za-z0-9]+$/.test(ext) ? ext : ".bin";
};
+54 -1
View File
@@ -1,6 +1,12 @@
export const VERSION = "0.0.0";
export { Client, type LoginOptions, type ClientSnapshot } from "./client.js";
export {
Client,
type LoginOptions,
type ClientSnapshot,
type CollectionsPage,
type FilesPage,
} from "./client.js";
export {
ApiClient,
ApiError,
@@ -27,6 +33,53 @@ export {
requestEmailOTP,
submitEmailOTP,
} from "./auth/login.js";
export {
Library,
DEFAULT_REFRESH_INTERVAL_SECONDS,
Album,
Photo,
type LibraryClient,
type LibraryOptions,
type LibraryStatus,
type RefreshEvent,
type RefreshProgressCallback,
type AlbumsAPI,
type PhotosAPI,
type TimelineAPI,
type PhotoFilter,
type TimelineGroup,
type GroupBy,
type ContentSource,
type ContentResult,
type ContentEvent,
type ContentOptions,
type PhotoContent,
type ThumbnailsAPI,
type ThumbnailPriority,
type EnsureOptions,
type EnsureResult,
type EnsureEvent,
runBackup,
type BackupOptions,
type BackupResult,
type BackupError,
} from "./library/index.js";
export {
RequestPools,
BoundedPool,
DEFAULT_METADATA_CONCURRENCY,
DEFAULT_CONTENT_CONCURRENCY,
DEFAULT_THUMBNAIL_CONCURRENCY,
type RequestPoolsOptions,
type Priority,
type RunOptions,
} from "./library/pools.js";
export type {
AlbumRecord,
PhotoRecord,
LibrarySnapshot,
LibraryChange,
} from "./library/records.js";
export { decryptCollection, decryptFile } from "./model/index.js";
export { downloadFile, downloadThumbnail } from "./download/index.js";
export type {
+675
View File
@@ -0,0 +1,675 @@
// The on-disk content and thumbnail cache keyed by fileID (issue #46).
//
// Layout under `cacheDirectory`: `originals/<fileID>.<ext>` and
// `thumbnails/<fileID>.<ext>`, flat directories at 0700 with files at 0600.
// Content appears only by the streaming atomic writer's rename (the download
// layer, #40), so a file that exists is whole — "present means complete". The
// directory listing taken at `open()` is the record of what is cached, and the
// orphan temp files a crashed write may have left are reaped there.
//
// A fetch goes through the shared request pools (#45): the content pool for
// 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.
//
// Integrity. The reused streaming decrypt is the enforced guarantee: every
// chunk is authenticated and the writer renames the file into place only once
// the stream ends on TAG_FINAL, so a truncated or corrupt fetch throws and
// nothing is stored. On top of that this module refuses to record a stored file
// that came out empty. The design also asks for a content-hash comparison
// against `FileMetadata.hash` (with a `fileSize` fallback); that is deferred —
// see the PR — because the exact hash construction cannot be confirmed against
// the repo's fixtures and `FileBlob.size` is the encrypted object size, not the
// decrypted length this layer has.
import { existsSync, statSync } from "node:fs";
import {
chmod,
mkdir,
readdir,
rm,
stat,
statfs,
utimes,
} from "node:fs/promises";
import { dirname, extname, join } from "node:path";
import type { ApiClient } from "../api/client.js";
import {
downloadFile,
downloadThumbnail,
type ProgressCallback,
} from "../download/index.js";
import { safeExtension } from "../filename.js";
import type { EnteFile } from "../model/types.js";
import type { Priority, RequestPools } from "./pools.js";
const DIR_MODE = 0o700;
const FILE_MODE = 0o600;
const TEMP_PREFIX = ".quak-";
const TEMP_SUFFIX = ".tmp";
const GIB = 1024 * 1024 * 1024;
// Owner ruling (#36): bound the originals cache at 100 GiB, but back off when
// the volume has under 50 GiB free so the cache never crowds the disk.
export const DEFAULT_ORIGINALS_MAX_BYTES = 100 * GIB;
export const DEFAULT_FREE_BELOW_BYTES = 50 * GIB;
// Ente thumbnails are always JPEG, so the cache stores them with a fixed
// extension rather than deriving one from the (image or video) title.
const THUMBNAIL_EXT = ".jpg";
type Kind = "original" | "thumbnail";
// An original write in progress, with the IDs of the concurrent original writes
// it overlaps (recorded both ways as writes begin, cleared when the write ends).
interface OriginalWrite {
fileID: number;
overlaps: Set<number>;
}
// The priority a caller attaches to a thumbnail prefetch. The pool has two
// tiers, so this three-value surface collapses onto them: only a currently
// visible thumbnail preempts (on-demand); "ahead" prefetch and speculative
// "background" work both yield to it.
export type ThumbnailPriority = "visible" | "ahead" | "background";
const poolPriorityOf = (priority: ThumbnailPriority): Priority =>
priority === "visible" ? "on-demand" : "background";
export interface ContentResult {
path: string;
bytes: number;
}
// Progress for a single `original`/`thumbnail` call. A present file emits one
// `skipped` event and nothing else; a fetched file emits `downloading` as
// plaintext lands and a final `done`.
export type ContentEvent =
| { status: "skipped"; bytes: number }
| { status: "downloading"; bytesDone: number }
| { status: "done"; bytes: number };
export interface ContentOptions {
onProgress?: (event: ContentEvent) => void;
}
// The Photo-facing content surface (the read wrappers call these). The cache
// implements it; a library opened without a content source leaves it absent.
export interface PhotoContent {
original(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
thumbnail(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
}
export interface EnsureResult {
fileID: number;
path?: string;
error?: string;
}
export interface EnsureEvent {
fileID: number;
status: "skipped" | "done" | "failed" | "aborted";
path?: string;
error?: string;
}
export interface EnsureOptions {
fileIDs: number[];
priority: ThumbnailPriority;
signal?: AbortSignal;
onProgress?: (event: EnsureEvent) => void;
}
export interface ThumbnailsAPI {
ensure(args: EnsureOptions): Promise<EnsureResult[]>;
}
// The byte source the cache fetches through. The real implementation streams
// and decrypts to the destination via the download layer; tests inject a
// stand-in so the cache logic runs with no crypto and no network. Pool routing,
// dedup, present-checks and integrity live in the cache, not here.
export interface ContentSource {
original(args: {
file: EnteFile;
destination: string;
onProgress?: ProgressCallback;
}): Promise<{ bytesWritten: number }>;
thumbnail(args: {
file: EnteFile;
destination: string;
onProgress?: ProgressCallback;
}): Promise<{ bytesWritten: number }>;
}
// The production source: each fetch is the download layer's request +
// streaming decrypt + atomic write + retry as one unit.
export const makeDownloadContentSource = (api: ApiClient): ContentSource => ({
original: ({ file, destination, onProgress }) =>
downloadFile(api, file, destination, onProgress),
thumbnail: ({ file, destination, onProgress }) =>
downloadThumbnail(api, file, destination, onProgress),
});
export interface CachedPaths {
originalPath?: string;
thumbnailPath?: string;
}
// The slice of `fs.statfs` the eviction limit needs: `bavail` is the blocks
// available to an unprivileged writer and `bsize` their size, so
// `bavail * bsize` is the free byte count. Injectable so tests drive the
// adaptive limit without a real volume.
export interface StatFsResult {
bsize: number;
bavail: number;
}
export type StatFsFn = (path: string) => Promise<StatFsResult>;
const realStatFs: StatFsFn = async (path) => {
const s = await statfs(path);
return { bsize: s.bsize, bavail: s.bavail };
};
// The current usage and effective limit of the originals cache, in bytes.
// `limitBytes` is the adaptive ceiling last computed (see `originalsLimit`).
export interface OriginalsStatus {
usedBytes: number;
limitBytes?: number;
}
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;
// 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;
// Hard ceiling on `cacheDirectory/originals` (default 100 GiB) and the free
// space to protect on the volume (default 50 GiB). The effective limit is
// the lesser of the ceiling and what fits above the protected free space.
cacheOriginalsMaxBytes?: number;
freeBelowBytes?: number;
// Whether an original is pinned (favorites + latest week; the precache unit
// #48 supplies the set). Pinned originals are never evicted; when only
// pinned originals remain the cache runs over-limit until the set shrinks.
isPinned?: (fileID: number) => boolean;
// Free-space probe on the volume holding `cacheDirectory`; defaults to the
// real `fs.statfs`.
statfs?: StatFsFn;
}
// Thrown inside a pooled task to drop a queued fetch that was aborted before it
// started running. Never escapes `ensureThumbnails`.
class AbortDrop extends Error {
constructor() {
super("aborted");
this.name = "AbortDrop";
}
}
const originalName = (file: EnteFile): string =>
`${file.id}${safeExtension(file.metadata.title)}`;
// 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 => {
const base = name.slice(0, name.length - extname(name).length);
if (!/^\d+$/.test(base)) return undefined;
const id = Number(base);
return Number.isSafeInteger(id) ? id : undefined;
};
// Size of a regular file, or undefined if it is absent (or not a regular file).
const fileSize = (path: string): number | undefined => {
try {
const s = statSync(path);
return s.isFile() ? s.size : undefined;
} catch {
return undefined;
}
};
export class ContentCache implements PhotoContent, ThumbnailsAPI {
private readonly pools: RequestPools;
private readonly source: ContentSource;
private readonly downloadDirectory?: string;
private readonly getFile: (fileID: number) => EnteFile | undefined;
private readonly originalsDir: string;
private readonly thumbnailsDir: string;
// fileID -> absolute path of the cached bytes, seeded from the directory
// listing at open() and extended as fetches store new files.
private readonly originals = new Map<number, string>();
private readonly thumbnails = new Map<number, string>();
private readonly maxOriginalsBytes: number;
private readonly freeBelowBytes: number;
private readonly isPinned: (fileID: number) => boolean;
private readonly statfs: StatFsFn;
// The last measured usage and effective limit, refreshed at open() and after
// every original write; exposed through `originalsStatus`.
private originalsUsedBytes = 0;
private originalsLimitBytes?: number;
// Serializes limit enforcement so concurrent original writes never race on
// the map or delete each other's just-freed room.
private enforcing: Promise<void> = Promise.resolve();
// Original writes in progress. Writes for different files run concurrently
// (the content pool), so an eviction pass must never delete a file whose
// fetch has not yet returned. Each entry records the IDs of the concurrent
// original writes it overlaps — noted both ways as writes begin — and a
// write's eviction pass spares them all. Bounded by the pool's concurrency,
// so eviction is never deferred beyond the active working set.
private readonly inFlightOriginals = new Set<OriginalWrite>();
constructor(opts: ContentCacheOptions) {
this.pools = opts.pools;
this.source = opts.source;
this.downloadDirectory = opts.downloadDirectory;
this.getFile = opts.getFile;
this.originalsDir = join(opts.cacheDirectory, "originals");
this.thumbnailsDir = join(opts.cacheDirectory, "thumbnails");
this.maxOriginalsBytes =
opts.cacheOriginalsMaxBytes ?? DEFAULT_ORIGINALS_MAX_BYTES;
this.freeBelowBytes = opts.freeBelowBytes ?? DEFAULT_FREE_BELOW_BYTES;
this.isPinned = opts.isPinned ?? (() => false);
this.statfs = opts.statfs ?? realStatFs;
}
// Prepare the cache directories, reap orphan temp files, and take the
// record of what is already cached. Called once before the cache serves.
async open(): Promise<void> {
await this.ensureDir(this.originalsDir);
await this.ensureDir(this.thumbnailsDir);
await this.scan(this.originalsDir, this.originals);
await this.scan(this.thumbnailsDir, this.thumbnails);
// Publish the current usage and limit without evicting; a restart
// reuses whatever survived on disk. Eviction only ever fires on a write.
await this.refreshOriginalsLimit();
}
// The current originals usage and effective limit, both in bytes, as of the
// last write or open. `status().originalsLimitBytes` surfaces this.
originalsStatus(): OriginalsStatus {
return {
usedBytes: this.originalsUsedBytes,
limitBytes: this.originalsLimitBytes,
};
}
// The cache paths known for a file, for the record projection to expose as
// `originalPath`/`thumbnailPath`.
pathsFor(fileID: number): CachedPaths {
const out: CachedPaths = {};
const original = this.originals.get(fileID);
if (original !== undefined) out.originalPath = original;
const thumbnail = this.thumbnails.get(fileID);
if (thumbnail !== undefined) out.thumbnailPath = thumbnail;
return out;
}
async original(
fileID: number,
opts?: ContentOptions,
): Promise<ContentResult> {
return this.get(fileID, "original", "on-demand", opts?.onProgress);
}
async thumbnail(
fileID: number,
opts?: ContentOptions,
): Promise<ContentResult> {
return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress);
}
async ensure(args: EnsureOptions): Promise<EnsureResult[]> {
return this.ensureThumbnails(args);
}
async ensureThumbnails(args: EnsureOptions): Promise<EnsureResult[]> {
return this.ensureMany(
"thumbnail",
poolPriorityOf(args.priority),
args.fileIDs,
args.signal,
args.onProgress,
);
}
// Fill originals through the content pool for the precache (#48), always at
// background priority so an on-demand `original()` preempts the fill. A
// present original is a map lookup and no fetch; a per-file failure is
// returned, not thrown, so one bad file never halts a background sweep.
async ensureOriginals(args: {
fileIDs: number[];
signal?: AbortSignal;
onProgress?: (event: EnsureEvent) => void;
}): Promise<EnsureResult[]> {
return this.ensureMany(
"original",
"background",
args.fileIDs,
args.signal,
args.onProgress,
);
}
private async ensureMany(
kind: Kind,
priority: Priority,
fileIDs: number[],
signal: AbortSignal | undefined,
onProgress: ((event: EnsureEvent) => void) | undefined,
): Promise<EnsureResult[]> {
// Dedup the request list so a repeated fileID is fetched once and
// reported once, in first-requested order.
const seen = new Set<number>();
const unique: number[] = [];
for (const id of fileIDs) {
if (!seen.has(id)) {
seen.add(id);
unique.push(id);
}
}
return Promise.all(
unique.map((fileID) =>
this.ensureOne(fileID, kind, priority, signal, onProgress),
),
);
}
private async ensureOne(
fileID: number,
kind: Kind,
priority: Priority,
signal: AbortSignal | undefined,
onProgress: ((event: EnsureEvent) => void) | undefined,
): Promise<EnsureResult> {
try {
const result = await this.acquire(fileID, kind, priority, signal);
const status = result.cached ? "skipped" : "done";
onProgress?.({ fileID, status, path: result.path });
return { fileID, path: result.path };
} catch (err) {
if (err instanceof AbortDrop) {
onProgress?.({ fileID, status: "aborted" });
return { fileID, error: "aborted" };
}
const error = err instanceof Error ? err.message : String(err);
onProgress?.({ fileID, status: "failed", error });
return { fileID, error };
}
}
private async get(
fileID: number,
kind: Kind,
priority: Priority,
onProgress: ((event: ContentEvent) => void) | undefined,
): Promise<ContentResult> {
const onByte: ProgressCallback | undefined = onProgress
? (bytesDone) => onProgress({ status: "downloading", bytesDone })
: undefined;
const result = await this.acquire(fileID, kind, priority, undefined, {
onByte,
});
onProgress?.(
result.cached
? { status: "skipped", bytes: result.bytes }
: { status: "done", bytes: result.bytes },
);
return { path: result.path, bytes: result.bytes };
}
// The core: return the cached path if present, else fetch through the pool,
// store, and return it. `cached` distinguishes a present hit (no network,
// no download event) from a fresh fetch.
private async acquire(
fileID: number,
kind: Kind,
priority: Priority,
signal: AbortSignal | undefined,
opts?: { onByte?: ProgressCallback },
): Promise<{ path: string; bytes: number; cached: boolean }> {
const file = this.getFile(fileID);
if (!file) throw new Error(`content cache: unknown file ${fileID}`);
const known = kind === "original" ? this.originals : this.thumbnails;
const cached = known.get(fileID);
if (cached !== undefined) {
const size = fileSize(cached);
if (size !== undefined && size > 0) {
// Returning an original's path is a use: bump its mtime so LRU
// order reflects it and survives a restart with no ledger.
if (
kind === "original" &&
dirname(cached) === this.originalsDir
)
await this.touch(cached);
return { path: cached, bytes: size, cached: true };
}
// A recorded file that has since gone re-fetches below.
known.delete(fileID);
}
// An original a backup already stored counts as present.
if (kind === "original" && this.downloadDirectory !== undefined) {
const backupPath = join(
this.downloadDirectory,
"originals",
originalName(file),
);
const size = fileSize(backupPath);
if (size !== undefined && size > 0) {
this.originals.set(fileID, backupPath);
return { path: backupPath, bytes: size, cached: true };
}
}
const dir =
kind === "original" ? this.originalsDir : this.thumbnailsDir;
const dest =
kind === "original"
? join(dir, originalName(file))
: join(dir, `${fileID}${THUMBNAIL_EXT}`);
const pool =
kind === "original" ? this.pools.content : this.pools.thumbnails;
return pool.run(
async () => {
// Dropping queued work on abort: a task still waiting for a slot
// when the signal fired sees it here and never touches the
// network. A task already past this point is in flight and runs
// to completion.
if (signal?.aborted) throw new AbortDrop();
// Register this original among those in flight, linking it with
// every sibling already writing so neither evicts the other's
// file. Non-null iff this is an original.
const write =
kind === "original"
? this.beginOriginalWrite(fileID)
: null;
try {
await this.download(file, dest, kind, opts?.onByte);
await chmod(dest, FILE_MODE);
const size = (await stat(dest)).size;
if (size === 0) {
throw new Error(
`content cache: ${kind} ${fileID} stored empty`,
);
}
known.set(fileID, dest);
// A fresh original may have crossed the limit; make room by
// evicting least-recently-used originals. An over-budget
// fetch keeps the file it returns, and no overlapping
// sibling is evicted. Thumbnails are never bounded.
if (write) await this.enforceOriginalsLimit(write);
return { path: dest, bytes: size, cached: false };
} finally {
if (write) this.inFlightOriginals.delete(write);
}
},
{ priority, key: fileID },
);
}
private async download(
file: EnteFile,
destination: string,
kind: Kind,
onProgress: ProgressCallback | undefined,
): Promise<number> {
const args = { file, destination, onProgress };
const result =
kind === "original"
? await this.source.original(args)
: await this.source.thumbnail(args);
return result.bytesWritten;
}
// Best-effort bump of a file's mtime to now; a failed touch must never fail
// the read it accompanies.
private async touch(path: string): Promise<void> {
const now = new Date();
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. Entries
// whose file has vanished are dropped from the map. Backups and thumbnails
// are never counted.
private async measureOriginals(): Promise<{
entries: {
fileID: number;
path: string;
size: number;
mtimeMs: number;
}[];
used: number;
}> {
const entries: {
fileID: number;
path: string;
size: number;
mtimeMs: number;
}[] = [];
let used = 0;
for (const [fileID, path] of this.originals) {
if (dirname(path) !== this.originalsDir) continue;
try {
const s = await stat(path);
entries.push({
fileID,
path,
size: s.size,
mtimeMs: s.mtimeMs,
});
used += s.size;
} catch {
this.originals.delete(fileID);
}
}
return { entries, used };
}
// The effective ceiling on originals: the configured max, but no more than
// what fits once the protected free space is set aside. `used + free` is the
// volume space the cache could occupy; subtracting `freeBelowBytes` leaves
// the reserve untouched. Clamped at zero.
private async originalsLimit(used: number): Promise<number> {
const { bsize, bavail } = await this.statfs(this.originalsDir);
const free = bsize * bavail;
const adaptive = used + free - this.freeBelowBytes;
return Math.max(0, Math.min(this.maxOriginalsBytes, adaptive));
}
// Recompute and publish usage and limit without evicting (used at open()).
private refreshOriginalsLimit(): Promise<void> {
return this.serializeEnforce(async () => {
const { used } = await this.measureOriginals();
this.originalsUsedBytes = used;
this.originalsLimitBytes = await this.originalsLimit(used);
});
}
// Record a starting original write among those in flight, linking it with
// every sibling already writing so neither can evict the other's file.
private beginOriginalWrite(fileID: number): OriginalWrite {
const write: OriginalWrite = { fileID, overlaps: new Set() };
for (const other of this.inFlightOriginals) {
write.overlaps.add(other.fileID);
other.overlaps.add(fileID);
}
this.inFlightOriginals.add(write);
return write;
}
// Evict least-recently-used originals until usage fits the limit. Skipped:
// pinned originals, the file `write` just stored, and every original whose
// write overlaps it (`write.overlaps`). The last two spare any fetch whose
// lifetime overlaps this one, so concurrent over-budget fetches all keep the
// paths they return; when only such originals remain the cache stays
// over-limit until they settle, and a later, non-overlapping write finds
// them eligible again.
private enforceOriginalsLimit(write: OriginalWrite): Promise<void> {
return this.serializeEnforce(async () => {
const { entries, used } = await this.measureOriginals();
const limit = await this.originalsLimit(used);
let remaining = used;
if (remaining > limit) {
const evictable = entries
.filter(
(e) =>
e.fileID !== write.fileID &&
!write.overlaps.has(e.fileID) &&
!this.isPinned(e.fileID),
)
.sort((a, b) => a.mtimeMs - b.mtimeMs);
for (const e of evictable) {
if (remaining <= limit) break;
await rm(e.path, { force: true });
this.originals.delete(e.fileID);
remaining -= e.size;
}
}
this.originalsUsedBytes = remaining;
this.originalsLimitBytes = limit;
});
}
// Run limit work one at a time; failures are swallowed so a transient
// statfs or unlink error never rejects the read or write that triggered it.
private serializeEnforce(work: () => Promise<void>): Promise<void> {
const next = this.enforcing.then(work).catch(() => undefined);
this.enforcing = next;
return next;
}
private async ensureDir(dir: string): Promise<void> {
// chmod after mkdir so the mode is tightened even when the directory
// already existed with a looser one; mkdir alone would not.
await mkdir(dir, { recursive: true, mode: DIR_MODE });
await chmod(dir, DIR_MODE);
}
private async scan(dir: string, into: Map<number, string>): Promise<void> {
let entries: string[];
try {
entries = await readdir(dir);
} catch {
return;
}
for (const name of entries) {
if (name.startsWith(TEMP_PREFIX) && name.endsWith(TEMP_SUFFIX)) {
await rm(join(dir, name), { force: true }).catch(
() => undefined,
);
continue;
}
const id = fileIDFromName(name);
const path = join(dir, name);
if (id !== undefined && existsSync(path)) into.set(id, path);
}
}
}
+851
View File
@@ -0,0 +1,851 @@
// The library surface over the local cache.
//
// `Library.open()` loads the on-disk metadata store (issue #41), then starts
// the refresh loop. When the cache loaded empty it awaits the first refresh,
// so the library never opens onto an empty store it could have filled; when an
// existing copy loaded, that first refresh runs in the background and `open()`
// returns as soon as the cached data is ready to serve — a slow or unreachable
// server no longer stalls opening. A background timer then refreshes every
// `refreshIntervalSeconds`. Every default read is answered from RAM — no
// default read touches the network. There is deliberately no `sync()`, no
// `refresh()`, no `serverReachable` flag, and no "before each read" mode
// (design #36).
//
// `fresh()` is the one exception (issue #75, an owner amendment to #36): it
// forces a refresh, awaits it, and only then hands back the read namespaces, so
// a caller that needs server-current data can ask for it. Concurrent `fresh()`
// calls coalesce onto one in-flight refresh, and a refresh that fails rejects
// the caller (the default reads stay silent and serve the last good copy). The
// default methods and the background loop are unchanged.
//
// 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
// failure surfaces through `onProgress` and `status()` instead. A commit that
// mutates RAM but then fails to persist keeps `status().lastError` set and the
// store marked unsaved until a later save actually lands, so a stuck disk is
// never masked by a subsequent empty refresh.
import { join } from "node:path";
import envPaths from "env-paths";
import { MetadataStore } from "./store.js";
import { MLDataStore } from "./mldata.js";
import { RequestPools } from "./pools.js";
import {
deriveRecords,
snapshotFrom,
diffRecords,
type DerivedRecords,
type LibrarySnapshot,
type LibraryChange,
} from "./records.js";
import {
makeAlbumsAPI,
makePhotosAPI,
makeTimelineAPI,
type AlbumsAPI,
type PhotosAPI,
type TimelineAPI,
type FreshReads,
} from "./read.js";
import {
ContentCache,
type ContentSource,
type ThumbnailsAPI,
type EnsureOptions,
type EnsureResult,
} from "./content.js";
import { makeMLDataAPI, type MLDataAPI } from "./mlsearch.js";
import { Precache } from "./precache.js";
export {
Album,
Photo,
type AlbumsAPI,
type PhotosAPI,
type TimelineAPI,
type FreshReads,
type PhotoFilter,
type TimelineGroup,
type GroupBy,
} from "./read.js";
export {
type ContentSource,
type ContentResult,
type ContentEvent,
type ContentOptions,
type PhotoContent,
type ThumbnailsAPI,
type ThumbnailPriority,
type EnsureOptions,
type EnsureResult,
type EnsureEvent,
} from "./content.js";
export { type MLDataAPI, type SimilarResult } from "./mlsearch.js";
import type { CollectionsPage, FilesPage } from "../client.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";
export {
runBackup,
type BackupOptions,
type BackupResult,
type BackupError,
} from "../backup.js";
export const DEFAULT_REFRESH_INTERVAL_SECONDS = 3;
// Project a metadata store into by-id records, filling each record's cache
// paths from the content cache when one is given. Shared by the live read
// projection and the precache's initial seeding at open().
const deriveRecordsFromStore = (
store: MetadataStore,
cache?: ContentCache,
): DerivedRecords => {
const collections = store.listCollections();
const files: EnteFile[] = [];
for (const c of collections) files.push(...store.listFiles(c.id));
return deriveRecords(
collections,
files,
cache ? (fileID) => cache.pathsFor(fileID) : undefined,
);
};
// The slice of `Client` the library depends on. Narrowing to an interface lets
// tests drive a mock with no crypto or network; the real `Client` satisfies it
// structurally.
export interface LibraryClient {
whoami(): { email: string; userID: number };
collectionsSince(args: { sinceTime: number }): Promise<CollectionsPage>;
filesSince(args: {
collectionID: number;
collectionKey: Uint8Array;
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.
fetchMLData?(args: {
fileIDs: number[];
fileKeys: Map<number, Uint8Array>;
}): Promise<Map<number, MLData>>;
// 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
// disabled and `Photo.original`/`thumbnail` and `thumbnails.ensure` throw.
contentSource?(): ContentSource;
}
// 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.
export interface RefreshEvent {
operation:
| "refresh"
| "fetchMLData"
| "precacheThumbnails"
| "precacheOriginals";
status: "started" | "done" | "failed";
error?: string;
fetched?: number;
}
export type RefreshProgressCallback = (event: RefreshEvent) => void;
export interface LibraryOptions {
client: LibraryClient;
// 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.
downloadDirectory?: string;
refreshIntervalSeconds?: number;
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
// design's caps.
pools?: RequestPools;
// Overrides the client's own `contentSource()`; mainly for tests that drive
// the cache with a stand-in source.
contentSource?: ContentSource;
// Bound on `cacheDirectory/originals` (default 100 GiB) and the free space
// to protect on its volume (default 50 GiB). The effective limit adapts
// down as the disk fills; `status().originalsLimitBytes` reports it.
cacheOriginalsMaxBytes?: number;
freeBelowBytes?: number;
// An extra pinned predicate OR-ed with the precache's own pinned set
// (favorites + latest week, #48). Pinned originals are never evicted.
isOriginalPinned?: (fileID: number) => boolean;
// The aggressive local precache (#48), all starting inside `open()` with no
// caller input. Thumbnails: every file, newest first, until all are on
// disk. Originals: the favorites album then the latest `precacheOriginalsDays`
// window (the days ending at the newest file). Both default on; the days
// default to 7.
precacheThumbnails?: boolean;
precacheOriginals?: boolean;
precacheOriginalsDays?: number;
}
export interface LibraryStatus {
userID: number;
collections: number;
files: number;
// Wall-clock ms of the last refresh that succeeded, or undefined if none
// has yet.
lastRefreshAt?: number;
// The message from the most recent refresh, set only while that refresh
// failed; cleared by the next success.
lastError?: string;
// Wall-clock ms of the last ML fetch pass that succeeded, or undefined if
// none has yet (or ML fetching is disabled).
lastMLFetchAt?: number;
// The most recent ML fetch pass's error, set only while it failed.
lastMLError?: string;
// ML payloads stored on disk and CLIP embeddings in the index; undefined
// when ML fetching is disabled.
mlStored?: number;
mlIndexed?: number;
// Bytes stored in the originals cache and the effective size limit as of the
// last write or open; undefined when no content cache is open.
originalsUsedBytes?: number;
originalsLimitBytes?: number;
// Precache progress (#48); undefined when no content cache is open. Totals
// are the files targeted (0 when a fill is disabled); "cached" is how many
// of them are on disk.
thumbnailsCached?: number;
thumbnailsTotal?: number;
originalsCached?: number;
originalsPinned?: number;
closed: boolean;
}
export class Library {
readonly cacheDirectory: string;
readonly downloadDirectory?: string;
// The in-process read surface (issue #44). Each namespace answers
// synchronously from the live record projection; no read touches the
// network.
readonly albums: AlbumsAPI;
readonly photos: PhotosAPI;
readonly timeline: TimelineAPI;
// The thumbnail-prefetch surface (issue #46): drives the thumbnail pool
// with priority, dedup, and abort.
readonly thumbnails: ThumbnailsAPI;
// The content-similarity search surface over the CLIP index (issue #50).
// Present whether or not ML fetching is enabled; with no ML store it
// returns empty results.
readonly mldata: MLDataAPI;
private readonly client: LibraryClient;
private readonly store: MetadataStore;
// The on-disk content cache, or undefined when no content source is
// available (a metadata-only client with no explicit source).
private readonly cache?: ContentCache;
private readonly userID: number;
private readonly intervalMs: number;
private readonly onProgress?: RefreshProgressCallback;
private readonly pools: RequestPools;
// The ML-data cache, present only when the client can fetch ML data.
private readonly mlStore?: MLDataStore;
// The local precache (#48), present only when the content cache is.
private readonly precache?: Precache;
private timer?: ReturnType<typeof setTimeout>;
// The in-flight refresh cycle, or undefined when none runs. One slot serves
// both paths: the background loop skips when it is set, and a fresh read
// (issue #75) coalesces onto it or starts one. The promise carries the
// cycle's real outcome (it rejects on failure); the background loop ignores
// that, a fresh read propagates it.
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.
private mlFetch?: Promise<void>;
private closed = false;
private lastRefreshAt?: number;
private lastError?: string;
private lastMLFetchAt?: number;
private lastMLError?: string;
// 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
// joins later diffs against the state its own `snapshot()` already returned.
private readonly subscribers = new Set<(change: LibraryChange) => void>();
private lastRecords: DerivedRecords;
// RAM holds changes disk has not yet accepted (an earlier save failed).
// Cleared only when a save actually succeeds; keeps the store trying to
// persist and the failure visible in `status()` until then.
private unsaved = false;
private constructor(args: {
client: LibraryClient;
store: MetadataStore;
userID: number;
cacheDirectory: string;
downloadDirectory?: string;
intervalMs: number;
onProgress?: RefreshProgressCallback;
pools: RequestPools;
mldata?: MLDataStore;
cache?: ContentCache;
precache?: Precache;
}) {
this.client = args.client;
this.store = args.store;
this.userID = args.userID;
this.cacheDirectory = args.cacheDirectory;
this.downloadDirectory = args.downloadDirectory;
this.intervalMs = args.intervalMs;
this.onProgress = args.onProgress;
this.pools = args.pools;
this.mlStore = args.mldata;
this.cache = args.cache;
this.precache = args.precache;
this.lastRecords = this.deriveNow();
// 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);
this.timeline = makeTimelineAPI(derive);
this.thumbnails = {
ensure: (opts: EnsureOptions): Promise<EnsureResult[]> => {
if (!this.cache) {
return Promise.reject(
new Error(
"thumbnails.ensure requires a library opened with a content cache",
),
);
}
return this.cache.ensureThumbnails(opts);
},
};
// Reads the ML store live so results grow as ML data is fetched.
this.mldata = makeMLDataAPI(() => this.mlStore);
}
// Load the cache and start the refresh loop. With an empty cache the first
// refresh is awaited, so `open()` resolves onto populated data whenever the
// server is reachable; that awaited refresh may still fail, and the 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.
static async open(opts: LibraryOptions): Promise<Library> {
const { userID } = opts.client.whoami();
const cacheDirectory =
opts.cacheDirectory ??
join(envPaths("quak", { suffix: "" }).cache, String(userID));
const store = await MetadataStore.load(
join(cacheDirectory, "metadata.json"),
);
const intervalMs =
(opts.refreshIntervalSeconds ?? DEFAULT_REFRESH_INTERVAL_SECONDS) *
1000;
// One request-pool set serves both the ML-data fetch and the content
// cache, so both honour the same concurrency caps.
const pools = opts.pools ?? new RequestPools();
// The ML cache only earns its keep when the client can fetch ML data;
// a client without that capability opens no `mldata/` directory.
const mldata = opts.client.fetchMLData
? await MLDataStore.open(join(cacheDirectory, "mldata"))
: undefined;
// Build the content cache from an explicit source or the client's own,
// and take its record of what is already cached (and reap orphan temp
// files) before the first projection, so cached paths are present from
// the start and the first refresh raises no spurious path-change diff.
const source = opts.contentSource ?? opts.client.contentSource?.();
let cache: ContentCache | undefined;
let precache: Precache | undefined;
if (source) {
// The precache owns the pinned set (favorites + latest week), which
// is the cache's eviction predicate. It is built and seeded from
// the loaded store first so the cache can wire `isPinned` to it, and
// then bound to the cache it fills. A caller-supplied predicate is
// OR-ed in so both survive.
precache = new Precache({
thumbnails: opts.precacheThumbnails,
originals: opts.precacheOriginals,
originalsDays: opts.precacheOriginalsDays,
onEvent: opts.onProgress,
});
precache.update(deriveRecordsFromStore(store));
const extraPinned = opts.isOriginalPinned;
cache = new ContentCache({
pools,
source,
cacheDirectory,
downloadDirectory: opts.downloadDirectory,
getFile: (fileID) => store.getFileByID(fileID),
cacheOriginalsMaxBytes: opts.cacheOriginalsMaxBytes,
freeBelowBytes: opts.freeBelowBytes,
isPinned: (fileID) =>
precache!.isPinned(fileID) ||
(extraPinned?.(fileID) ?? false),
});
await cache.open();
precache.bind(cache);
}
const lib = new Library({
client: opts.client,
store,
userID,
cacheDirectory,
downloadDirectory: opts.downloadDirectory,
intervalMs,
onProgress: opts.onProgress,
pools,
mldata,
cache,
precache,
});
// Start filling from whatever the loaded store already holds; each
// refresh below re-kicks with the new files (and retries any that
// failed). An empty store starts empty here and fills after its first
// refresh.
precache?.start();
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());
} else {
// Nothing was cached: wait for the first refresh to fill the store
// (or fail) rather than resolve onto an empty library.
await lib.runRefresh();
lib.scheduleNext();
}
return lib;
}
listCollections(): Collection[] {
return this.store.listCollections();
}
getCollection(id: number): Collection | undefined {
return this.store.getCollection(id);
}
listFiles(collectionID: number): EnteFile[] {
return this.store.listFiles(collectionID);
}
getFile(collectionID: number, fileID: number): EnteFile | undefined {
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.
getFileByID(fileID: number): EnteFile | undefined {
return this.store.getFileByID(fileID);
}
// A synchronous, RAM-only projection of the whole library into plain
// records (no keys), the surface the GUI reads across IPC. Photos are
// deduplicated to one record per file and ordered newest first.
snapshot(): LibrarySnapshot {
return snapshotFrom(this.deriveNow(), Date.now());
}
// Deliver a `LibraryChange` whenever a refresh alters the projection. A
// refresh that changes nothing delivers nothing. The returned handle's
// `unsubscribe` stops delivery.
subscribe(args: { onChange: (change: LibraryChange) => void }): {
unsubscribe: () => void;
} {
const { onChange } = args;
this.subscribers.add(onChange);
return {
unsubscribe: () => {
this.subscribers.delete(onChange);
},
};
}
status(): LibraryStatus {
let files = 0;
const collections = this.store.listCollections();
for (const c of collections) {
files += this.store.listFiles(c.id).length;
}
const ml = this.mlStore?.stats();
const originals = this.cache?.originalsStatus();
const pre = this.precache?.status();
return {
userID: this.store.userID,
collections: collections.length,
files,
lastRefreshAt: this.lastRefreshAt,
lastError: this.lastError,
lastMLFetchAt: this.lastMLFetchAt,
lastMLError: this.lastMLError,
mlStored: ml?.stored,
mlIndexed: ml?.indexed,
originalsUsedBytes: originals?.usedBytes,
originalsLimitBytes: originals?.limitBytes,
thumbnailsCached: pre?.thumbnailsCached,
thumbnailsTotal: pre?.thumbnailsTotal,
originalsCached: pre?.originalsCached,
originalsPinned: pre?.originalsPinned,
closed: this.closed,
};
}
// Fresh reads (issue #75, owner amendment to design #36). Force a refresh,
// wait for it to complete and persist, then hand back the same
// `albums`/`photos`/`timeline` namespaces — now guaranteed to reflect a
// completed server round-trip. Concurrent calls coalesce onto one refresh;
// a refresh that fails rejects here, where the default namespaces would
// instead stay silent and serve the last good copy.
async fresh(): Promise<FreshReads> {
await this.refreshNow();
return {
albums: this.albums,
photos: this.photos,
timeline: this.timeline,
};
}
// Back up every in-scope file to `downloadDirectory` in the historical
// on-disk layout, with a durable failure ledger (issue #51). Refreshes
// first, fetches pending originals (and optional thumbnails) through the
// content cache and pools, then 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.
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(
"backup requires a library opened with a content cache",
),
);
}
const cache = this.cache;
return runBackup(
{
refresh: () => this.runRefresh(),
listCollections: () => this.store.listCollections(),
listFiles: (id) => this.store.listFiles(id),
original: (fileID) => cache!.original(fileID),
thumbnail: (fileID) => cache!.thumbnail(fileID),
},
{ ...opts, downloadDirectory },
);
}
// Stop the background timer. Idempotent. An in-flight refresh is left to
// finish; it will not schedule another cycle once closed. The returned
// promise resolves once that refresh (including its cache write), the ML
// fetch pass and the precache fetches already running have all finished,
// so a caller can then remove the cache directory. A refresh failure is
// reported through `status()`, not thrown here.
async close(): Promise<void> {
this.closed = true;
const precacheClosed = this.precache?.close();
if (this.timer !== undefined) {
clearTimeout(this.timer);
this.timer = undefined;
}
await this.cycle?.catch(() => {});
await this.mlFetch;
await precacheClosed;
}
private scheduleNext(): void {
if (this.closed) return;
this.timer = setTimeout(() => {
void this.runRefresh().then(() => this.scheduleNext());
}, this.intervalMs);
// Do not keep the process alive for the sake of the timer.
this.timer.unref?.();
}
// The background loop's refresh: run a cycle unless one is already in flight
// (or the library is closed), and never let a failure escape — the
// background path reports errors through `status()`/`onProgress`, it does
// not throw. Resolves once the cycle it started (or skipped past) settles.
private runRefresh(): Promise<void> {
if (this.closed || this.cycle) return Promise.resolve();
return this.startCycle().catch(() => {});
}
// A fresh read's refresh (issue #75): force a cycle and await it, rejecting
// if it fails. Concurrent fresh reads coalesce onto the one in-flight cycle
// — the background loop's included — so they never fan out into redundant
// server round-trips.
private refreshNow(): Promise<void> {
if (this.closed) {
return Promise.reject(new Error("the library is closed"));
}
return this.cycle ?? this.startCycle();
}
// Start one refresh cycle and record it as the in-flight cycle so every
// caller coalesces onto it. The returned promise carries the cycle's real
// outcome; each caller attaches the handling its own path needs, and the
// slot is cleared once the cycle settles.
private startCycle(): Promise<void> {
const cycle = this.refreshCycle();
this.cycle = cycle;
void cycle.then(
() => {
if (this.cycle === cycle) this.cycle = undefined;
},
() => {
if (this.cycle === cycle) this.cycle = undefined;
},
);
return cycle;
}
// One refresh cycle: the network fetch and commit, wrapped in the progress
// events and status bookkeeping. Throws when the refresh fails so a fresh
// read can reject; `runRefresh` swallows that throw for the background loop.
private async refreshCycle(): Promise<void> {
this.emit({ operation: "refresh", status: "started" });
try {
await this.refreshOnce();
this.lastRefreshAt = Date.now();
this.lastError = undefined;
this.emit({ operation: "refresh", status: "done" });
// 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;
});
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
this.lastError = error;
this.emit({ operation: "refresh", status: "failed", error });
throw err;
}
}
// 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.
private async refreshOnce(): Promise<void> {
const page = await this.client.collectionsSince({
sinceTime: this.store.collectionsSinceTime,
});
// 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.
const filePages: { collectionID: number; page: FilesPage }[] = [];
for (const collection of page.collections) {
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 });
}
// Network work done; commit to the store and persist only if something
// actually changed.
let changed = false;
if (this.store.userID !== this.userID) {
this.store.userID = this.userID;
changed = true;
}
for (const id of page.deleted) {
if (this.store.getCollection(id)) {
this.store.deleteCollection(id);
changed = true;
}
}
for (const collection of page.collections) {
this.store.putCollection(collection);
changed = true;
}
for (const { collectionID, page: filePage } of filePages) {
for (const id of filePage.deleted) {
if (this.store.getFile(collectionID, id)) {
this.store.deleteFile(collectionID, id);
changed = true;
}
}
for (const f of filePage.files) {
this.store.putFile(f);
changed = true;
}
}
if (page.cursor !== this.store.collectionsSinceTime) {
this.store.collectionsSinceTime = page.cursor;
changed = true;
}
if (changed) this.unsaved = true;
// Reproject and notify subscribers of the delta. This tracks RAM (what
// reads see), so it fires whether or not the save below succeeds; a
// save failure surfaces separately through `status().lastError`.
// `lastRecords` advances every changed refresh so the next diff is
// against current state.
if (changed) {
const next = this.deriveNow();
if (this.subscribers.size > 0) {
const change = diffRecords(this.lastRecords, next, Date.now());
if (change) this.notify(change);
}
this.lastRecords = next;
// Recompute the fill orders and pinned set against the new library.
this.precache?.update(next);
}
// Re-kick the fills every cycle: a finished sweep starts afresh to pick
// up new files and retry any that failed, and a running one is left be.
this.precache?.start();
// Persist whenever RAM holds changes disk has not accepted — including
// changes an earlier cycle staged whose save failed. `unsaved` clears
// only once a save lands, so a save failure both stays visible through
// `status().lastError` (the throw below records it) and keeps being
// retried, instead of a later empty refresh silently clearing it while
// the on-disk cache is still behind RAM.
if (this.unsaved) {
await this.store.save();
this.unsaved = false;
}
}
// 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.
private async runMLFetch(): Promise<void> {
const mldata = this.mlStore;
// Bind so the call keeps the client as its receiver when invoked
// through the pool below.
const fetchMLData = this.client.fetchMLData?.bind(this.client);
if (!mldata || !fetchMLData || this.closed) return;
const files = this.uniqueFiles();
const needed = mldata.neededFor(files);
if (needed.length === 0) return;
this.emit({ operation: "fetchMLData", status: "started" });
try {
const fileKeys = new Map<number, Uint8Array>();
const updation = new Map<number, number>();
for (const f of files) {
fileKeys.set(f.id, f.key);
updation.set(f.id, f.updationTime);
}
let stored = 0;
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(
() => fetchMLData({ fileIDs: batch, fileKeys }),
{ priority: "background" },
);
stored += (await mldata.storeFetched(payloads, updation))
.stored;
}
this.lastMLFetchAt = Date.now();
this.lastMLError = undefined;
this.emit({
operation: "fetchMLData",
status: "done",
fetched: stored,
});
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
this.lastMLError = error;
this.emit({ operation: "fetchMLData", status: "failed", error });
}
}
// 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.
private uniqueFiles(): {
id: number;
key: Uint8Array;
updationTime: number;
}[] {
const byID = new Map<
number,
{ id: number; key: Uint8Array; updationTime: number }
>();
for (const collection of this.store.listCollections()) {
for (const f of this.store.listFiles(collection.id)) {
const seen = byID.get(f.id);
if (seen === undefined || f.updationTime > seen.updationTime)
byID.set(f.id, {
id: f.id,
key: f.key,
updationTime: f.updationTime,
});
}
}
return [...byID.values()];
}
// Gather every file membership and project the store into by-id records,
// filling each record's cache paths from the content cache when present.
private deriveNow(): DerivedRecords {
return deriveRecordsFromStore(this.store, this.cache);
}
private notify(change: LibraryChange): void {
for (const onChange of this.subscribers) {
// A misbehaving subscriber must not break the loop or its peers.
try {
onChange(change);
} catch {
// ignore
}
}
}
private emit(event: RefreshEvent): void {
if (!this.onProgress) return;
// A misbehaving callback must not break the refresh loop.
try {
this.onProgress(event);
} catch {
// ignore
}
}
}
+378
View File
@@ -0,0 +1,378 @@
// The on-disk cache of Ente's per-file machine-learning data and the CLIP
// index derived from it (issue #49).
//
// Under `<cacheDirectory>/mldata/` this keeps:
//
// - `<fileID>.json` — one decrypted, gunzipped payload per file, written by
// rename. Its presence means it is complete: a torn write never leaves a
// half-file, so the set of these files is the source of truth for what is
// cached. The full payload (face boxes, landmarks, embeddings) is read back
// from here on demand and never held in RAM.
//
// - `clip.f32` + `clip.json` — the derived index the content search runs on.
// `clip.json` lists the indexed fileIDs in order plus the embedding length;
// `clip.f32` is those CLIP embeddings packed as one `Float32Array`, so the
// index loads in a single read with no per-vector parse. The index is
// rebuilt from the payloads whenever it is missing or structurally
// disagrees with the files present, and appended to as new payloads arrive.
//
// - `fetched.json` — a small map of fileID to the `updationTime` it was
// fetched at. This is best-effort bookkeeping for refetch decisions (a file
// whose `updationTime` later advances is refetched); the payloads, not this
// file, remain the record of what is cached, so losing it only forgoes
// update-driven refetch until the next fetch rewrites it.
//
// In RAM this holds only the id list and the packed `Float32Array`.
import { mkdir, readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import { writeAtomic } from "../download/index.js";
import type { MLData } from "../mldata-fetch.js";
const CLIP_VECTORS = "clip.f32";
const CLIP_INDEX = "clip.json";
const FETCHED = "fetched.json";
// A payload file is named for its fileID alone; the derived files above are
// not, so this pattern picks out payloads and nothing else.
const PAYLOAD_RE = /^(\d+)\.json$/;
const BYTES_PER_FLOAT = 4;
// The on-disk form of `clip.json`.
interface ClipIndexFile {
fileIDs: number[];
embeddingLength: number;
}
// A file the model knows about, for deciding what to fetch.
export interface MLDataFile {
id: number;
updationTime: number;
}
// The RAM index the search reads: `fileIDs[i]` owns the `embeddingLength`
// floats of `embeddings` starting at `i * embeddingLength`.
export interface MLIndex {
fileIDs: number[];
embeddingLength: number;
embeddings: Float32Array;
}
// Pull the CLIP embedding out of a payload, or undefined when it is absent or
// misshapen. Kept strict so a bad payload is skipped rather than corrupting the
// packed index.
const clipEmbedding = (payload: MLData): number[] | undefined => {
const clip = payload.clip;
if (typeof clip !== "object" || clip === null) return undefined;
const embedding = (clip as { embedding?: unknown }).embedding;
if (!Array.isArray(embedding)) return undefined;
if (embedding.some((v) => typeof v !== "number" || !Number.isFinite(v)))
return undefined;
return embedding as number[];
};
export class MLDataStore {
readonly dir: string;
// fileIDs whose payload JSON is present on disk (present means complete).
private readonly present = new Set<number>();
// fileID -> updationTime it was fetched at.
private readonly fetched = new Map<number, number>();
// The packed index and where each id sits in it.
private ids: number[] = [];
private embeddingLength = 0;
private embeddings = new Float32Array(0);
private readonly pos = new Map<number, number>();
private constructor(dir: string) {
this.dir = dir;
}
// Open (creating the directory) and load the id list and packed index into
// RAM, rebuilding the index from the payloads when it is missing or does
// not match the files present.
static async open(dir: string): Promise<MLDataStore> {
const store = new MLDataStore(dir);
await mkdir(dir, { recursive: true });
await store.loadPresent();
await store.loadFetched();
if (!(await store.tryLoadIndex())) await store.rebuildIndex();
return store;
}
// The fileIDs among `files` that must be fetched: every file with no
// payload yet (first run, then new files), plus any whose `updationTime`
// has advanced past the one its cached payload was fetched at. Returned
// sorted and unique.
neededFor(files: MLDataFile[]): number[] {
const latest = new Map<number, number>();
for (const f of files) {
const seen = latest.get(f.id);
if (seen === undefined || f.updationTime > seen)
latest.set(f.id, f.updationTime);
}
const needed: number[] = [];
for (const [id, updationTime] of latest) {
if (!this.present.has(id)) {
needed.push(id);
continue;
}
const at = this.fetched.get(id);
if (at !== undefined && updationTime > at) needed.push(id);
}
return needed.sort((a, b) => a - b);
}
// Store a batch of fetched payloads: write one file per id, fold their CLIP
// embeddings into the packed index (in place for a refetch, appended for a
// new file), and persist the derived files. Returns how many payloads were
// stored and how many ids the index now holds.
async storeFetched(
payloads: Map<number, MLData>,
updation: Map<number, number>,
): Promise<{ stored: number; indexed: number }> {
if (payloads.size === 0) return { stored: 0, indexed: this.ids.length };
for (const [id, payload] of payloads) {
await this.writePayload(id, payload);
this.present.add(id);
const at = updation.get(id);
if (at !== undefined) this.fetched.set(id, at);
}
const updates: { at: number; vector: number[] }[] = [];
const appends: { id: number; vector: number[] }[] = [];
for (const [id, payload] of payloads) {
const vector = clipEmbedding(payload);
if (!vector) continue;
if (this.embeddingLength === 0 && this.ids.length === 0)
this.embeddingLength = vector.length;
// The index is fixed-width; a vector of another length (never seen
// from Ente's CLIP model) is stored but left out of the index.
if (vector.length !== this.embeddingLength) continue;
const at = this.pos.get(id);
if (at !== undefined) updates.push({ at, vector });
else appends.push({ id, vector });
}
for (const { at, vector } of updates)
this.embeddings.set(vector, at * this.embeddingLength);
if (appends.length > 0) {
const length = this.embeddingLength;
const grown = new Float32Array(
this.embeddings.length + appends.length * length,
);
grown.set(this.embeddings);
let offset = this.embeddings.length;
for (const { id, vector } of appends) {
grown.set(vector, offset);
this.pos.set(id, this.ids.length);
this.ids.push(id);
offset += length;
}
this.embeddings = grown;
}
await this.persistIndex();
await this.persistFetched();
return { stored: payloads.size, indexed: this.ids.length };
}
// The packed index the search runs on. The id list is copied so callers
// cannot disturb the store's own order; the embeddings are the live buffer.
getIndex(): MLIndex {
return {
fileIDs: [...this.ids],
embeddingLength: this.embeddingLength,
embeddings: this.embeddings,
};
}
// The full payload for a file, read from disk, or undefined when it is not
// cached or does not parse.
async readPayload(fileID: number): Promise<MLData | undefined> {
if (!this.present.has(fileID)) return undefined;
let raw: string;
try {
raw = await readFile(this.payloadPath(fileID), "utf-8");
} catch {
return undefined;
}
try {
return JSON.parse(raw) as MLData;
} catch {
return undefined;
}
}
stats(): { stored: number; indexed: number } {
return { stored: this.present.size, indexed: this.ids.length };
}
private payloadPath(id: number): string {
return join(this.dir, `${id}.json`);
}
private async writePayload(id: number, payload: MLData): Promise<void> {
await writeAtomic(
this.payloadPath(id),
new TextEncoder().encode(JSON.stringify(payload)),
);
}
private async loadPresent(): Promise<void> {
let names: string[];
try {
names = await readdir(this.dir);
} catch {
return;
}
for (const name of names) {
const match = PAYLOAD_RE.exec(name);
if (match) this.present.add(Number(match[1]));
}
}
private async loadFetched(): Promise<void> {
let raw: string;
try {
raw = await readFile(join(this.dir, FETCHED), "utf-8");
} catch {
return;
}
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
for (const [key, value] of Object.entries(parsed)) {
const id = Number(key);
if (
Number.isInteger(id) &&
typeof value === "number" &&
this.present.has(id)
)
this.fetched.set(id, value);
}
} catch {
// Corrupt bookkeeping degrades refetch decisions, never fails open.
}
}
// Load the packed index if it is present and agrees with the payloads in
// both directions: every id it names must still be present, its vector file
// must be exactly the size the id count and embedding length imply, and no
// embedding-bearing payload on disk may be missing from it. Returns whether
// it loaded.
private async tryLoadIndex(): Promise<boolean> {
let metaRaw: string;
try {
metaRaw = await readFile(join(this.dir, CLIP_INDEX), "utf-8");
} catch {
return false;
}
let meta: ClipIndexFile;
try {
meta = JSON.parse(metaRaw) as ClipIndexFile;
} catch {
return false;
}
if (
!Array.isArray(meta.fileIDs) ||
typeof meta.embeddingLength !== "number"
)
return false;
if (meta.fileIDs.some((id) => !this.present.has(id))) return false;
// The reverse must hold too. A payload carrying an embedding but absent
// from the index means the index is stale — realistically the process
// died after storeFetched renamed the payloads into place but before it
// rewrote clip.json/clip.f32. Loading such an index as "consistent"
// would drop those embeddings for good (neededFor sees the payloads
// present and never refetches), so treat it as a disagreement and
// rebuild. Only present ids the index omits are read; a payload
// legitimately without an embedding stays out and forces no rebuild.
const indexed = new Set(meta.fileIDs);
for (const id of this.present) {
if (indexed.has(id)) continue;
const payload = await this.readPayload(id);
if (payload && clipEmbedding(payload)) return false;
}
let bytes: Buffer;
try {
bytes = await readFile(join(this.dir, CLIP_VECTORS));
} catch {
return false;
}
const expected =
meta.fileIDs.length * meta.embeddingLength * BYTES_PER_FLOAT;
if (bytes.byteLength !== expected) return false;
// One read, no parse: copy into an aligned buffer and view it as
// floats. The copy is needed because a Buffer from the pool can start
// at an offset a Float32Array cannot be laid over.
const aligned = new Uint8Array(bytes.byteLength);
aligned.set(bytes);
this.embeddings = new Float32Array(aligned.buffer);
this.embeddingLength = meta.embeddingLength;
this.ids = [...meta.fileIDs];
this.pos.clear();
this.ids.forEach((id, i) => this.pos.set(id, i));
return true;
}
// Rebuild the packed index by reading every payload present, then persist
// it. Payloads without a CLIP embedding (or of an unexpected length) are
// simply not indexed.
private async rebuildIndex(): Promise<void> {
this.ids = [];
this.pos.clear();
this.embeddingLength = 0;
const vectors: number[][] = [];
for (const id of [...this.present].sort((a, b) => a - b)) {
const payload = await this.readPayload(id);
if (!payload) continue;
const vector = clipEmbedding(payload);
if (!vector) continue;
if (this.embeddingLength === 0)
this.embeddingLength = vector.length;
if (vector.length !== this.embeddingLength) continue;
this.pos.set(id, this.ids.length);
this.ids.push(id);
vectors.push(vector);
}
const length = this.embeddingLength;
const packed = new Float32Array(this.ids.length * length);
vectors.forEach((vector, i) => packed.set(vector, i * length));
this.embeddings = packed;
await this.persistIndex();
}
private async persistIndex(): Promise<void> {
const meta: ClipIndexFile = {
fileIDs: this.ids,
embeddingLength: this.embeddingLength,
};
await writeAtomic(
join(this.dir, CLIP_INDEX),
new TextEncoder().encode(JSON.stringify(meta)),
);
await writeAtomic(
join(this.dir, CLIP_VECTORS),
new Uint8Array(
this.embeddings.buffer,
this.embeddings.byteOffset,
this.embeddings.byteLength,
),
);
}
private async persistFetched(): Promise<void> {
const record: Record<string, number> = {};
for (const [id, at] of this.fetched) record[id] = at;
await writeAtomic(
join(this.dir, FETCHED),
new TextEncoder().encode(JSON.stringify(record)),
);
}
}
+129
View File
@@ -0,0 +1,129 @@
// The content-similarity search surface over the CLIP index (issue #50).
//
// This is `lib.mldata`. It answers three questions against the ML-data cache
// (#49) without touching the network:
//
// - `forFile` returns the whole stored payload (face boxes, landmarks,
// embedding) for a file, read from disk on demand — the only method here
// that touches the disk, and the only one that is async.
// - `similar` and `searchByEmbedding` rank fileIDs by cosine similarity over
// the packed `Float32Array` index alone. That index (~50k×512) already
// lives in RAM, so each query is a plain loop over it and nothing else.
//
// quak bundles no text encoder (owner-deferred), so `searchByEmbedding` takes
// the query vector the caller has produced elsewhere; `similar` uses the
// query file's own indexed embedding.
import type { MLData } from "../mldata-fetch.js";
import type { MLDataStore, MLIndex } from "./mldata.js";
// How many nearest files a query returns when the caller names no limit.
const DEFAULT_LIMIT = 20;
// One ranked result: a fileID and its cosine similarity to the query, in
// [-1, 1]. Callers wanting only the ids read `.fileID`.
export interface SimilarResult {
fileID: number;
score: number;
}
export interface MLDataAPI {
// The whole stored ML payload for a file, or undefined when it is not
// cached. Reads the payload from disk, so it is async.
forFile(args: { fileID: number }): Promise<MLData | undefined>;
// The files nearest the given file by cosine over their CLIP embeddings,
// most similar first, excluding the file itself. Empty when the file has
// no indexed embedding.
similar(args: { fileID: number; limit?: number }): SimilarResult[];
// The files nearest a caller-supplied query embedding by cosine, most
// similar first. Empty when the query is the wrong length for the index,
// has zero magnitude, or the index is empty.
searchByEmbedding(args: {
embedding: ArrayLike<number>;
limit?: number;
}): SimilarResult[];
}
// Rank the packed index by cosine similarity to `query`, most similar first,
// and return the top `limit`. `skip` (a query file's own id) is left out. Both
// each row's magnitude and the query's are computed here rather than cached:
// the index mutates as ML data is fetched, and one plain pass over ~50k×512
// floats is fast enough that a norm cache would only add a staleness bug. A
// zero-magnitude vector has no direction, so it is dropped rather than divided
// by zero.
const topByCosine = (
index: MLIndex,
query: ArrayLike<number>,
limit: number,
skip?: number,
): SimilarResult[] => {
const { fileIDs, embeddingLength, embeddings } = index;
if (embeddingLength === 0 || query.length !== embeddingLength) return [];
// Every indexed read below is in range: the inner loops run to
// `embeddingLength`, the query is exactly that long (checked above), and
// the packed buffer holds `fileIDs.length * embeddingLength` floats.
// `noUncheckedIndexedAccess` still widens each read to `number | undefined`,
// so they are asserted non-null rather than paying a per-element guard in
// this hot ~50k×512 loop.
let queryNorm = 0;
for (let k = 0; k < embeddingLength; k++) {
const q = query[k]!;
queryNorm += q * q;
}
queryNorm = Math.sqrt(queryNorm);
if (queryNorm === 0) return [];
const results: SimilarResult[] = [];
for (let i = 0; i < fileIDs.length; i++) {
const id = fileIDs[i]!;
if (id === skip) continue;
const base = i * embeddingLength;
let dot = 0;
let norm = 0;
for (let k = 0; k < embeddingLength; k++) {
const v = embeddings[base + k]!;
dot += query[k]! * v;
norm += v * v;
}
if (norm === 0) continue;
results.push({
fileID: id,
score: dot / (queryNorm * Math.sqrt(norm)),
});
}
// Descending score, ties broken by ascending fileID for a stable order.
results.sort((a, b) => b.score - a.score || a.fileID - b.fileID);
return results.slice(0, Math.max(0, Math.trunc(limit)));
};
// Build the search surface over a store the library supplies lazily (the store
// is absent when the client cannot fetch ML data). Reading it per call keeps
// the surface current as the index grows.
export const makeMLDataAPI = (
store: () => MLDataStore | undefined,
): MLDataAPI => ({
forFile: ({ fileID }): Promise<MLData | undefined> => {
const s = store();
return s ? s.readPayload(fileID) : Promise.resolve(undefined);
},
similar: ({ fileID, limit }): SimilarResult[] => {
const s = store();
if (!s) return [];
const index = s.getIndex();
const pos = index.fileIDs.indexOf(fileID);
if (pos < 0) return [];
const base = pos * index.embeddingLength;
const query = index.embeddings.subarray(
base,
base + index.embeddingLength,
);
return topByCosine(index, query, limit ?? DEFAULT_LIMIT, fileID);
},
searchByEmbedding: ({ embedding, limit }): SimilarResult[] => {
const s = store();
if (!s) return [];
return topByCosine(s.getIndex(), embedding, limit ?? DEFAULT_LIMIT);
},
});
+181
View File
@@ -0,0 +1,181 @@
// Three bounded request pools for metadata, content, and thumbnails (issue
// #45).
//
// Ente meters differently by traffic class, so quak keeps three independent
// pools instead of one global limit: metadata is cheap and chatty, original
// content is heavy, thumbnails are small but numerous. Each pool is a
// `BoundedPool` — a plain concurrency limiter — and the three run at the
// design's caps (10 / 5 / 25) unless the caller overrides them.
//
// Two behaviours beyond a bare limiter, both per pool:
//
// - Priority: work waiting for a slot is ordered on-demand before
// background/precache, so a slot that frees up serves the request a user is
// waiting on ahead of speculative prefetch. Within one priority the order
// is first-come-first-served.
//
// - In-flight dedup: a task submitted under a `key` that a still-pending task
// already carries is not run a second time; both callers await the one
// result. The key is released the moment that task settles — success or
// failure — so a later request for the same key runs afresh. Callers key by
// the id whose fetch must not be duplicated (a fileID, say).
//
// The pool holds a task's slot for that task's entire lifetime. A task that
// retries internally is doing so inside its slot: the slot is not freed between
// attempts, which is what keeps a retrying request counted against the cap. The
// pool knows nothing of the retry policy; it only holds the slot until the
// task's promise settles.
//
// This module is self-contained infrastructure. Routing a given `Client` call
// to the right pool belongs to the unit that wires the pools into the cache;
// here there is only the machinery.
export const DEFAULT_METADATA_CONCURRENCY = 10;
export const DEFAULT_CONTENT_CONCURRENCY = 5;
export const DEFAULT_THUMBNAIL_CONCURRENCY = 25;
// On-demand work is served before background/precache work waiting in the same
// pool.
export type Priority = "on-demand" | "background";
export interface RunOptions {
// Defaults to "background": an unmarked request yields to on-demand work.
priority?: Priority;
// When set, a task already pending under this key is shared instead of run
// again. Omit for work that must always execute.
key?: string | number;
}
// One queued submission awaiting a slot. `start` runs the task and holds the
// slot until it settles.
interface Waiter {
priority: Priority;
// Submission order, used to break ties within a priority (FIFO).
seq: number;
start: () => void;
}
export class BoundedPool {
readonly concurrency: number;
private active = 0;
private nextSeq = 0;
private readonly waiting: Waiter[] = [];
// Keyed by a caller-supplied dedup key; holds the shared promise for as
// long as that task is pending, cleared when it settles.
private readonly pending = new Map<string | number, Promise<unknown>>();
constructor(concurrency: number) {
if (!Number.isInteger(concurrency) || concurrency < 1) {
throw new RangeError(
`concurrency must be a positive integer, got ${concurrency}`,
);
}
this.concurrency = concurrency;
}
// Submit `task` to the pool. It runs once a slot is free, subject to
// priority; the returned promise settles with the task's result. With a
// `key`, a still-pending submission under the same key is returned instead
// of running `task` again.
run<T>(task: () => Promise<T>, opts: RunOptions = {}): Promise<T> {
const { key } = opts;
if (key !== undefined) {
const shared = this.pending.get(key);
if (shared !== undefined) return shared as Promise<T>;
}
const promise = this.enqueue(task, opts.priority ?? "background");
if (key !== undefined) {
this.pending.set(key, promise);
const release = (): void => {
// Only clear our own entry: a fresh submission under the same
// key after this one settled must not be evicted here.
if (this.pending.get(key) === promise) this.pending.delete(key);
};
promise.then(release, release);
}
return promise;
}
private enqueue<T>(task: () => Promise<T>, priority: Priority): Promise<T> {
return new Promise<T>((resolve, reject) => {
const start = (): void => {
this.active++;
// Hold the slot until the task fully settles — every internal
// retry included — then admit the next waiter.
void (async () => {
try {
resolve(await task());
} catch (err) {
reject(err);
} finally {
this.active--;
this.pump();
}
})();
};
this.waiting.push({ priority, seq: this.nextSeq++, start });
this.pump();
});
}
// Admit waiters until the pool is full or the queue is empty.
private pump(): void {
while (this.active < this.concurrency) {
const next = this.takeNext();
if (next === undefined) return;
next.start();
}
}
// Remove and return the highest-priority waiter: on-demand before
// background, earliest submission first within a priority.
private takeNext(): Waiter | undefined {
let bestIndex = -1;
let best: Waiter | undefined;
for (let i = 0; i < this.waiting.length; i++) {
const w = this.waiting[i];
if (w === undefined) continue;
if (best === undefined || this.precedes(w, best)) {
best = w;
bestIndex = i;
}
}
if (best === undefined) return undefined;
this.waiting.splice(bestIndex, 1);
return best;
}
private precedes(a: Waiter, b: Waiter): boolean {
if (a.priority !== b.priority) return a.priority === "on-demand";
return a.seq < b.seq;
}
}
export interface RequestPoolsOptions {
metadataConcurrency?: number;
contentConcurrency?: number;
thumbnailConcurrency?: number;
}
// The three pools the design calls for, each independent: an idle pool never
// lends its slots to a busy one.
export class RequestPools {
readonly metadata: BoundedPool;
readonly content: BoundedPool;
readonly thumbnails: BoundedPool;
constructor(opts: RequestPoolsOptions = {}) {
this.metadata = new BoundedPool(
opts.metadataConcurrency ?? DEFAULT_METADATA_CONCURRENCY,
);
this.content = new BoundedPool(
opts.contentConcurrency ?? DEFAULT_CONTENT_CONCURRENCY,
);
this.thumbnails = new BoundedPool(
opts.thumbnailConcurrency ?? DEFAULT_THUMBNAIL_CONCURRENCY,
);
}
}
+269
View File
@@ -0,0 +1,269 @@
// The aggressive local precache (issue #48), started from `Library.open` with
// no caller input.
//
// Two background fills run concurrently through the shared request pools (#45):
//
// - Thumbnails: every file in the account, newest first, through the
// thumbnail pool until all are on disk. Never evicted. The pool is the same
// one `thumbnails.ensure` uses, so a visible or ahead request always jumps
// ahead of this background fill and a fileID both want is fetched once.
//
// - Originals (the pinned set): through the content pool, the favorites album
// first, then every file whose `takenAt` falls in the latest
// `originalsDays` window — the days ending at the newest file in the
// account. The pinned set is the eviction predicate (#47): a pinned
// original is never evicted, and a file that leaves the set (a favorite
// removed, or the window moving past it on a later refresh) becomes an
// ordinary, evictable original with its bytes left in place.
//
// Both fills yield to on-demand work: every fetch goes to its pool at
// background priority, which the pool serves only after on-demand requests. A
// file already cached costs one map lookup (`pathsFor`) and no fetch. Each
// sweep is driven in bounded chunks so the pool's waiting queue never grows to
// the whole account, keeping on-demand preemption and per-admit cost cheap on a
// large library. Failures are not fatal: an uncached file is retried on the
// next sweep, which `Library` re-kicks after every refresh.
import type { EnsureResult } from "./content.js";
import type { DerivedRecords } from "./records.js";
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
export const DEFAULT_PRECACHE_ORIGINALS_DAYS = 7;
// How many files a sweep submits to a pool before awaiting them. Bounds the
// pool's waiting queue so on-demand work is never stuck behind the whole
// account; the values track each pool's concurrency (#45).
const THUMBNAIL_CHUNK = 25;
const ORIGINAL_CHUNK = 5;
// The precache metrics `Library.status()` surfaces.
export interface PrecacheStatus {
thumbnailsCached: number;
thumbnailsTotal: number;
originalsCached: number;
originalsPinned: number;
}
// A background-fill progress event. Each active sweep fires "started" before
// its fetches and "done" (with the count newly on disk) after; a sweep with
// nothing left to fetch is silent.
export interface PrecacheEvent {
operation: "precacheThumbnails" | "precacheOriginals";
status: "started" | "done";
fetched?: number;
}
// The slice of the content cache the precache drives. The real `ContentCache`
// satisfies it; tests inject a fake.
export interface PrecacheCache {
pathsFor(fileID: number): { originalPath?: string; thumbnailPath?: string };
ensureThumbnails(args: {
fileIDs: number[];
priority: "background";
signal?: AbortSignal;
}): Promise<EnsureResult[]>;
ensureOriginals(args: {
fileIDs: number[];
signal?: AbortSignal;
}): Promise<EnsureResult[]>;
}
export interface PrecacheOptions {
// Default true; false disables the fill and, for originals, the pinning.
thumbnails?: boolean;
originals?: boolean;
// The latest-week window length in days; default 7.
originalsDays?: number;
onEvent?: (event: PrecacheEvent) => void;
}
export class Precache {
private readonly doThumbnails: boolean;
private readonly doOriginals: boolean;
private readonly originalsDays: number;
private readonly onEvent?: (event: PrecacheEvent) => void;
private cache?: PrecacheCache;
// Every file in the account, newest first (the thumbnail fill order).
private thumbOrder: number[] = [];
// The pinned originals in fetch order: favorites first, then the window.
private originalsOrder: number[] = [];
private pinned = new Set<number>();
// A sweep runs at most once per fill at a time; a re-kick while one runs is
// a no-op, and the next refresh re-kicks after it finishes. Each holds the
// running sweep, so `close()` can wait for it.
private thumbSweep?: Promise<void>;
private originalsSweep?: Promise<void>;
private readonly aborter = new AbortController();
private closed = false;
constructor(opts: PrecacheOptions = {}) {
this.doThumbnails = opts.thumbnails ?? true;
this.doOriginals = opts.originals ?? true;
this.originalsDays =
opts.originalsDays ?? DEFAULT_PRECACHE_ORIGINALS_DAYS;
this.onEvent = opts.onEvent;
}
// Attach the cache the fills fetch through. `isPinned` works before this is
// called, so the cache can be constructed with `isPinned` wired in and then
// bound here.
bind(cache: PrecacheCache): void {
this.cache = cache;
}
// Whether an original is pinned (favorites + the latest-week window), the
// eviction predicate (#47). False for every file when originals precaching
// is disabled.
isPinned(fileID: number): boolean {
return this.pinned.has(fileID);
}
// Recompute the fill orders and the pinned set from the current projection.
// Called at open and after every refresh that changes the library.
update(records: DerivedRecords): void {
const photos = [...records.photos.values()].sort(
(a, b) => b.takenAt - a.takenAt || b.fileID - a.fileID,
);
this.thumbOrder = this.doThumbnails ? photos.map((p) => p.fileID) : [];
const pinned = new Set<number>();
const order: number[] = [];
if (this.doOriginals) {
// Favorites first, in the album's own newest-first order.
for (const album of records.albums.values()) {
if (album.type !== "favorites") continue;
for (const id of album.fileIDs) {
if (!pinned.has(id)) {
pinned.add(id);
order.push(id);
}
}
}
// Then the latest-week window, ending at the newest file. Photos
// are newest first, so stop once one falls before the window start.
const newest = photos[0];
if (newest !== undefined) {
const windowStart =
newest.takenAt - this.originalsDays * ONE_DAY_MS;
for (const p of photos) {
if (p.takenAt < windowStart) break;
if (!pinned.has(p.fileID)) {
pinned.add(p.fileID);
order.push(p.fileID);
}
}
}
}
this.pinned = pinned;
this.originalsOrder = order;
}
status(): PrecacheStatus {
const cache = this.cache;
let thumbnailsCached = 0;
let originalsCached = 0;
if (cache) {
for (const id of this.thumbOrder)
if (cache.pathsFor(id).thumbnailPath !== undefined)
thumbnailsCached++;
for (const id of this.originalsOrder)
if (cache.pathsFor(id).originalPath !== undefined)
originalsCached++;
}
return {
thumbnailsCached,
thumbnailsTotal: this.thumbOrder.length,
originalsCached,
originalsPinned: this.originalsOrder.length,
};
}
// Kick both fills. Idempotent: a fill already sweeping is left alone. Safe
// to call after every refresh; a finished fill starts a fresh sweep that
// picks up new files and retries any that failed before.
start(): void {
if (this.closed || !this.cache) return;
if (this.doThumbnails) this.kickThumbnails();
if (this.doOriginals) this.kickOriginals();
}
// Stop the fills. In-flight fetches are left to settle; queued ones drop.
// Resolves once both sweeps have finished, so nothing is still writing.
async close(): Promise<void> {
this.closed = true;
this.aborter.abort();
await Promise.all([
this.thumbSweep?.catch(() => {}),
this.originalsSweep?.catch(() => {}),
]);
}
private kickThumbnails(): void {
if (this.thumbSweep) return;
this.thumbSweep = this.sweep(
"precacheThumbnails",
() => this.thumbOrder,
(id) => this.cache!.pathsFor(id).thumbnailPath !== undefined,
THUMBNAIL_CHUNK,
(chunk) =>
this.cache!.ensureThumbnails({
fileIDs: chunk,
priority: "background",
signal: this.aborter.signal,
}),
).finally(() => {
this.thumbSweep = undefined;
});
}
private kickOriginals(): void {
if (this.originalsSweep) return;
this.originalsSweep = this.sweep(
"precacheOriginals",
() => this.originalsOrder,
(id) => this.cache!.pathsFor(id).originalPath !== undefined,
ORIGINAL_CHUNK,
(chunk) =>
this.cache!.ensureOriginals({
fileIDs: chunk,
signal: this.aborter.signal,
}),
).finally(() => {
this.originalsSweep = undefined;
});
}
// One fill sweep: skip files already on disk (one lookup each), fetch the
// rest in bounded chunks, and report progress only when there was work.
private async sweep(
operation: PrecacheEvent["operation"],
order: () => number[],
present: (fileID: number) => boolean,
chunkSize: number,
fetch: (chunk: number[]) => Promise<EnsureResult[]>,
): Promise<void> {
const todo = order().filter((id) => !present(id));
if (todo.length === 0) return;
this.emit({ operation, status: "started" });
let fetched = 0;
for (let i = 0; i < todo.length && !this.closed; i += chunkSize) {
const results = await fetch(todo.slice(i, i + chunkSize));
for (const r of results) if (r.path !== undefined) fetched++;
}
this.emit({ operation, status: "done", fetched });
}
private emit(event: PrecacheEvent): void {
if (!this.onEvent) return;
// A misbehaving callback must not break the fill loop.
try {
this.onEvent(event);
} catch {
// ignore
}
}
}
+388
View File
@@ -0,0 +1,388 @@
// The in-process read surface over the local cache (issue #44).
//
// A CLI or an in-process script reads albums, photos, and a grouped timeline
// through `lib.albums`, `lib.photos`, and `lib.timeline`. Every call is
// answered synchronously from the same plain-record projection the GUI reads
// (`deriveRecords`, issue #43); nothing here touches the network. Every method
// takes a single named-argument object.
//
// The `Album` and `Photo` classes are thin, in-process-only wrappers over
// those records: a caller that holds an object reference gets typed field
// 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.
import type { CollectionType, FileType } from "../model/types.js";
import type { ContentOptions, ContentResult, PhotoContent } from "./content.js";
import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js";
// 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 =>
b.takenAt - a.takenAt || b.fileID - a.fileID;
// Albums newest updated first, collection id breaking ties. This is the order
// `albums.list` returns and the order `byName` resolves a name collision in.
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.
export class Photo {
constructor(
private readonly rec: PhotoRecord,
private readonly content?: PhotoContent,
) {}
get fileID(): number {
return this.rec.fileID;
}
get albumIDs(): number[] {
return this.rec.albumIDs;
}
get title(): string {
return this.rec.title;
}
get takenAt(): number {
return this.rec.takenAt;
}
get fileType(): FileType {
return this.rec.fileType;
}
get caption(): string | undefined {
return this.rec.caption;
}
get width(): number | undefined {
return this.rec.width;
}
get height(): number | undefined {
return this.rec.height;
}
get latitude(): number | undefined {
return this.rec.latitude;
}
get longitude(): number | undefined {
return this.rec.longitude;
}
get isArchived(): boolean {
return this.rec.isArchived;
}
get isHidden(): boolean {
return this.rec.isHidden;
}
record(): PhotoRecord {
return this.rec;
}
// Fetch and cache the full-resolution original, returning its on-disk path
// and byte length. Served from the cache (or the backup download directory)
// when already present, otherwise fetched through the content pool.
async original(opts?: ContentOptions): Promise<ContentResult> {
return this.contentOrThrow().original(this.rec.fileID, opts);
}
// As `original`, for the thumbnail, through the thumbnail pool.
async thumbnail(opts?: ContentOptions): Promise<ContentResult> {
return this.contentOrThrow().thumbnail(this.rec.fileID, opts);
}
private contentOrThrow(): PhotoContent {
if (!this.content) {
throw new Error(
"Photo content requires a library opened with a content cache",
);
}
return this.content;
}
}
// A single album. `photos.list()` returns the album's photos as wrappers,
// newest first (the record already stores `fileIDs` in that order).
export class Album {
constructor(
private readonly rec: AlbumRecord,
private readonly records: DerivedRecords,
private readonly content?: PhotoContent,
) {}
get collectionID(): number {
return this.rec.collectionID;
}
get name(): string {
return this.rec.name;
}
get type(): CollectionType {
return this.rec.type;
}
get isShared(): boolean {
return this.rec.isShared;
}
get updationTime(): number {
return this.rec.updationTime;
}
get fileIDs(): number[] {
return this.rec.fileIDs;
}
get photos(): { list: () => Photo[] } {
return { list: (): Photo[] => this.listPhotos() };
}
record(): AlbumRecord {
return this.rec;
}
private listPhotos(): Photo[] {
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));
}
return out;
}
}
export interface AlbumsAPI {
list(): Album[];
byName(args: { albumName: string }): Album | undefined;
byID(args: { collectionID: number }): Album | undefined;
}
export interface PhotosAPI {
byID(args: { fileID: number }): Photo | undefined;
// Plain records for the requested ids, in the order requested, each id at
// most once, unknown ids dropped.
records(args: { fileIDs: number[] }): PhotoRecord[];
}
export type GroupBy = "day" | "week" | "month";
// A filter over the timeline. All fields are optional and combine with AND.
// Hidden photos are never included, regardless of this filter.
export interface PhotoFilter {
// Keep only photos that belong to this album.
albumID?: number;
// Case-insensitive substring of the title, caption, or any album name the
// photo belongs to.
text?: string;
// Keep only photos of one of these types.
fileTypes?: FileType[];
// `true` keeps only geotagged photos; `false` keeps only those without a
// location; omitted places no constraint.
hasLocation?: boolean;
// Archived photos are excluded unless this is `true`. Defaults to `false`.
includeArchived?: boolean;
}
export interface TimelineGroup {
// The period's identity: `YYYY-MM-DD` for day, `YYYY-Www` (ISO 8601 week,
// e.g. `2025-W32`) for week, and `YYYY-MM` for month.
key: string;
// Local-time milliseconds at the start of the period.
startsAt: number;
// The period's files, newest first, each file once.
fileIDs: number[];
}
export interface TimelineAPI {
groups(args: { groupBy: GroupBy; filter?: PhotoFilter }): TimelineGroup[];
}
// The surface `Library.fresh()` resolves to (issue #75). It is the same three
// read namespaces as the default `albums`/`photos`/`timeline`, handed back only
// after a forced refresh has brought the local copy current.
export interface FreshReads {
albums: AlbumsAPI;
photos: PhotosAPI;
timeline: TimelineAPI;
}
export const makeAlbumsAPI = (
derive: () => DerivedRecords,
content?: PhotoContent,
): AlbumsAPI => ({
list: (): Album[] => {
const records = derive();
return [...records.albums.values()]
.sort(byNewestAlbum)
.map((rec) => new Album(rec, records, content));
},
byID: ({ collectionID }): Album | undefined => {
const records = derive();
const rec = records.albums.get(collectionID);
return rec ? new Album(rec, records, content) : undefined;
},
byName: ({ albumName }): Album | undefined => {
const records = derive();
// Names are not unique in Ente; resolve a collision deterministically
// to the newest-updated album, matching `list` order.
const match = [...records.albums.values()]
.sort(byNewestAlbum)
.find((rec) => rec.name === albumName);
return match ? new Album(match, records, content) : undefined;
},
});
export const makePhotosAPI = (
derive: () => DerivedRecords,
content?: PhotoContent,
): PhotosAPI => ({
byID: ({ fileID }): Photo | undefined => {
const rec = derive().photos.get(fileID);
return rec ? new Photo(rec, content) : undefined;
},
records: ({ fileIDs }): PhotoRecord[] => {
const { photos } = derive();
const seen = new Set<number>();
const out: PhotoRecord[] = [];
for (const id of fileIDs) {
if (seen.has(id)) continue;
const rec = photos.get(id);
if (rec) {
out.push(rec);
seen.add(id);
}
}
return out;
},
});
export const makeTimelineAPI = (derive: () => DerivedRecords): TimelineAPI => ({
groups: ({ groupBy, filter }): TimelineGroup[] => {
const records = derive();
return groupPhotos(filterPhotos(records, filter), groupBy);
},
});
// Apply a `PhotoFilter` to the projection. Hidden photos are always dropped;
// archived photos are dropped unless `includeArchived` asks for them.
const filterPhotos = (
records: DerivedRecords,
filter?: PhotoFilter,
): PhotoRecord[] => {
const f = filter ?? {};
const includeArchived = f.includeArchived ?? false;
const needle = f.text?.toLowerCase();
const out: PhotoRecord[] = [];
for (const rec of records.photos.values()) {
if (rec.isHidden) continue;
if (rec.isArchived && !includeArchived) continue;
if (f.albumID !== undefined && !rec.albumIDs.includes(f.albumID))
continue;
if (f.fileTypes !== undefined && !f.fileTypes.includes(rec.fileType))
continue;
if (f.hasLocation !== undefined) {
const has =
rec.latitude !== undefined && rec.longitude !== undefined;
if (has !== f.hasLocation) continue;
}
if (needle !== undefined && !matchesText(rec, needle, records))
continue;
out.push(rec);
}
return out;
};
const matchesText = (
rec: PhotoRecord,
needle: string,
records: DerivedRecords,
): boolean => {
if (rec.title.toLowerCase().includes(needle)) return true;
if (rec.caption !== undefined && rec.caption.toLowerCase().includes(needle))
return true;
for (const id of rec.albumIDs) {
const album = records.albums.get(id);
if (album && album.name.toLowerCase().includes(needle)) return true;
}
return false;
};
// Bucket photos into periods, groups newest first, members newest first.
const groupPhotos = (
photos: PhotoRecord[],
groupBy: GroupBy,
): TimelineGroup[] => {
const buckets = new Map<
string,
{ startsAt: number; recs: PhotoRecord[] }
>();
for (const rec of photos) {
const { key, startsAt } = periodOf(rec.takenAt, groupBy);
const bucket = buckets.get(key);
if (bucket) bucket.recs.push(rec);
else buckets.set(key, { startsAt, recs: [rec] });
}
const groups: TimelineGroup[] = [];
for (const [key, bucket] of buckets) {
bucket.recs.sort(byNewest);
groups.push({
key,
startsAt: bucket.startsAt,
fileIDs: bucket.recs.map((r) => r.fileID),
});
}
groups.sort((a, b) => b.startsAt - a.startsAt);
return groups;
};
const pad = (n: number): string => String(n).padStart(2, "0");
const dateKey = (d: Date): string =>
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
// The ISO 8601 week key `YYYY-Www` for the week starting at the given Monday.
// The week-year is the year of that week's Thursday, so it can differ from the
// calendar year at the January/December boundary (e.g. 2024-12-30 is 2025-W01).
const isoWeekKey = (monday: Date): string => {
const thursday = new Date(
monday.getFullYear(),
monday.getMonth(),
monday.getDate() + 3,
);
const isoYear = thursday.getFullYear();
// Thursday of ISO week 1 is the Thursday of the week containing January 4.
const jan4 = new Date(isoYear, 0, 4);
const week1Thursday = new Date(
isoYear,
0,
4 + 3 - ((jan4.getDay() + 6) % 7),
);
const week =
1 +
Math.round((thursday.getTime() - week1Thursday.getTime()) / WEEK_MS);
return `${isoYear}-W${pad(week)}`;
};
// The period a millisecond instant falls in, in local time. Weeks start on
// Monday. `Date` normalizes out-of-range day arguments, so the week's Monday
// is correct across month and year boundaries.
const periodOf = (
takenAt: number,
groupBy: GroupBy,
): { key: string; startsAt: number } => {
const d = new Date(takenAt);
const year = d.getFullYear();
const month = d.getMonth();
const day = d.getDate();
if (groupBy === "month") {
const start = new Date(year, month, 1);
return { key: `${year}-${pad(month + 1)}`, startsAt: start.getTime() };
}
if (groupBy === "week") {
// getDay(): 0=Sunday..6=Saturday; shift so Monday is the week start.
const fromMonday = (d.getDay() + 6) % 7;
const start = new Date(year, month, day - fromMonday);
return { key: isoWeekKey(start), startsAt: start.getTime() };
}
const start = new Date(year, month, day);
return { key: dateKey(start), startsAt: start.getTime() };
};
+270
View File
@@ -0,0 +1,270 @@
// Plain records projected from the decrypted store, and the diff between two
// projections. These are the library's GUI-facing surface: they hold no key
// material and no binary, so they survive `structuredClone`/JSON across the
// Electron IPC boundary where methods and file keys cannot go (design #36,
// 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.
import type {
Collection,
CollectionType,
EnteFile,
FileType,
} from "../model/types.js";
// Ente private-magic-metadata visibility values.
const VISIBILITY_ARCHIVED = 1;
const VISIBILITY_HIDDEN = 2;
// A single photo, deduplicated across the collections it belongs to. No key,
// no binary: safe to send to a window.
export interface PhotoRecord {
fileID: number;
// Every collection this file is a member of, ascending.
albumIDs: number[];
// `pubMagicMetadata.editedName` when the user renamed the file, else the
// basic-metadata title.
title: string;
// Milliseconds. `pubMagicMetadata.editedTime` when the user edited the
// date, else basic-metadata `creationTime`.
takenAt: number;
fileType: FileType;
caption?: string;
width?: number;
height?: number;
latitude?: number;
longitude?: number;
isArchived: boolean;
isHidden: boolean;
// Local cache paths, set once a later phase caches the bytes; unset here.
thumbnailPath?: string;
originalPath?: string;
}
export interface AlbumRecord {
collectionID: number;
name: string;
// `favorites` identifies the account's favorites album.
type: CollectionType;
isShared: boolean;
updationTime: number;
// The album's files, newest first.
fileIDs: number[];
}
export interface LibrarySnapshot {
albums: AlbumRecord[];
photos: PhotoRecord[];
// Wall-clock milliseconds when the snapshot was taken.
takenAt: number;
}
export interface LibraryChange {
// Full records for albums/photos added or changed by the refresh.
albumsChanged: AlbumRecord[];
photosChanged: PhotoRecord[];
fileIDsRemoved: number[];
albumIDsRemoved: number[];
// Wall-clock milliseconds of the refresh that produced this change.
refreshedAt: number;
}
// The by-id projection of the store at one moment; the source for both
// `snapshotFrom` (sorted arrays for the GUI) and `diffRecords` (change sets).
export interface DerivedRecords {
albums: Map<number, AlbumRecord>;
photos: Map<number, PhotoRecord>;
}
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 microsToMillis = (micros: number): number => Math.floor(micros / 1000);
// Newest first, with fileID as a stable tiebreak so equal-timed files order
// deterministically.
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) =>
m.updationTime > best.updationTime ||
(m.updationTime === best.updationTime &&
m.collectionID < best.collectionID)
? m
: best,
);
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),
fileType: rep.metadata.fileType,
isArchived: visibility === VISIBILITY_ARCHIVED,
isHidden: visibility === VISIBILITY_HIDDEN,
};
const caption = asString(pub.caption);
if (caption !== undefined) record.caption = caption;
const width = asNumber(pub.w);
if (width !== undefined) record.width = width;
const height = asNumber(pub.h);
if (height !== undefined) record.height = height;
if (rep.metadata.latitude !== undefined)
record.latitude = rep.metadata.latitude;
if (rep.metadata.longitude !== undefined)
record.longitude = rep.metadata.longitude;
return record;
};
const toAlbumRecord = (
collection: Collection,
files: EnteFile[],
takenAtByFile: Map<number, number>,
): AlbumRecord => {
const fileIDs = files
.filter((f) => f.collectionID === collection.id)
.map((f) => f.id)
.sort(
(a, b) =>
(takenAtByFile.get(b) ?? 0) - (takenAtByFile.get(a) ?? 0) ||
b - a,
);
return {
collectionID: collection.id,
name: collection.name,
type: collection.type,
isShared: collection.isShared,
updationTime: collection.updationTime,
fileIDs,
};
};
// The cache paths known for a file, so the projection can expose them on the
// record without the read layer reaching into the content cache itself.
export type CachedPathLookup = (fileID: number) => {
originalPath?: string;
thumbnailPath?: string;
};
// Project the decrypted collections and file memberships into by-id records.
// `files` is every membership (a file appears once per collection it is in).
// `cachedPaths`, when given, fills each record's cache paths.
export const deriveRecords = (
collections: Collection[],
files: EnteFile[],
cachedPaths?: CachedPathLookup,
): DerivedRecords => {
const byFileID = new Map<number, EnteFile[]>();
for (const f of files) {
const arr = byFileID.get(f.id);
if (arr) arr.push(f);
else byFileID.set(f.id, [f]);
}
const photos = new Map<number, PhotoRecord>();
const takenAtByFile = new Map<number, number>();
for (const [fileID, memberships] of byFileID) {
const record = toPhotoRecord(fileID, memberships);
if (cachedPaths) {
const paths = cachedPaths(fileID);
if (paths.originalPath !== undefined)
record.originalPath = paths.originalPath;
if (paths.thumbnailPath !== undefined)
record.thumbnailPath = paths.thumbnailPath;
}
photos.set(fileID, record);
takenAtByFile.set(fileID, record.takenAt);
}
const albums = new Map<number, AlbumRecord>();
for (const c of collections) {
albums.set(c.id, toAlbumRecord(c, files, takenAtByFile));
}
return { albums, photos };
};
// Sorted, GUI-ready arrays: albums newest updated first, photos newest first.
export const snapshotFrom = (
records: DerivedRecords,
takenAt: number,
): LibrarySnapshot => ({
albums: [...records.albums.values()].sort(
(a, b) =>
b.updationTime - a.updationTime || b.collectionID - a.collectionID,
),
photos: [...records.photos.values()].sort(byNewestPhoto),
takenAt,
});
// Records compare by value; they are plain and built with a fixed key order, so
// a serialized form is a sound equality key.
const same = (a: unknown, b: unknown): boolean =>
JSON.stringify(a) === JSON.stringify(b);
const diffMap = <T>(
prev: Map<number, T>,
next: Map<number, T>,
): { changed: T[]; removed: number[] } => {
const changed: T[] = [];
for (const [id, record] of next) {
const before = prev.get(id);
if (before === undefined || !same(before, record)) changed.push(record);
}
const removed: number[] = [];
for (const id of prev.keys()) if (!next.has(id)) removed.push(id);
removed.sort((a, b) => a - b);
return { changed, removed };
};
// The change between two projections, or undefined when nothing changed.
export const diffRecords = (
prev: DerivedRecords,
next: DerivedRecords,
refreshedAt: number,
): LibraryChange | undefined => {
const albums = diffMap(prev.albums, next.albums);
const photos = diffMap(prev.photos, next.photos);
if (
albums.changed.length === 0 &&
albums.removed.length === 0 &&
photos.changed.length === 0 &&
photos.removed.length === 0
) {
return undefined;
}
return {
albumsChanged: albums.changed,
photosChanged: photos.changed,
fileIDsRemoved: photos.removed,
albumIDsRemoved: albums.removed,
refreshedAt,
};
};
+205
View File
@@ -0,0 +1,205 @@
// On-disk JSON metadata store for the local cache.
//
// 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.
//
// 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
// the refresh unit then repopulates it.
import { mkdir, chmod, readFile } from "node:fs/promises";
import { dirname } from "node:path";
import { writeAtomic } from "../download/index.js";
import type { Collection, EnteFile, Microseconds } from "../model/types.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
// from the server is always safe and cheaper than migrating a cache.
export const METADATA_SCHEMA_VERSION = 1;
// Directory and file modes match `session.json`: the records hold decrypted
// key material, so on a shared machine only the owner may read them.
const DIR_MODE = 0o700;
const FILE_MODE = 0o600;
// On-disk shapes. They mirror the in-memory model exactly except for the
// binary `key`, which JSON cannot hold and which is stored as base64.
type StoredCollection = Omit<Collection, "key"> & { key: string };
type StoredFile = Omit<EnteFile, "key"> & { key: string };
interface StoredMetadata {
schemaVersion: number;
userID: number;
collectionsSinceTime: Microseconds;
collections: StoredCollection[];
files: StoredFile[];
}
const encodeKey = (key: Uint8Array): string =>
Buffer.from(key).toString("base64");
const decodeKey = (encoded: string): Uint8Array =>
new Uint8Array(Buffer.from(encoded, "base64"));
// A file membership is identified by the pair (collectionID, fileID): the same
// underlying file can belong to several collections, each a distinct record
// with its own key.
const fileKey = (collectionID: number, fileID: number): string =>
`${collectionID}:${fileID}`;
export class MetadataStore {
readonly path: string;
readonly schemaVersion = METADATA_SCHEMA_VERSION;
userID = 0;
collectionsSinceTime: Microseconds = 0;
// True when `load` populated this store from a valid existing file; false
// on a first run or a missing/corrupt/wrong-version file that loaded empty.
// `Library.open` reads it to decide whether the first refresh may run in
// the background (an existing copy already serves reads) or must be awaited.
loadedFromDisk = false;
private readonly collections = new Map<number, Collection>();
private readonly files = new Map<string, EnteFile>();
private constructor(path: string) {
this.path = path;
}
// Load the store at `path`. A missing file, an unreadable one, unparseable
// contents, or a mismatched schema version all yield an empty store bound
// to that path — never a thrown error, because the file is only a cache.
static async load(path: string): Promise<MetadataStore> {
const store = new MetadataStore(path);
let raw: string;
try {
raw = await readFile(path, "utf8");
} catch {
return store;
}
try {
const parsed = JSON.parse(raw) as StoredMetadata;
if (parsed.schemaVersion !== METADATA_SCHEMA_VERSION) {
return store;
}
store.loadedFromDisk = true;
store.userID = parsed.userID ?? 0;
store.collectionsSinceTime = parsed.collectionsSinceTime ?? 0;
for (const stored of parsed.collections ?? []) {
const collection: Collection = {
...stored,
key: decodeKey(stored.key),
};
store.collections.set(collection.id, collection);
}
for (const stored of parsed.files ?? []) {
const file: EnteFile = {
...stored,
key: decodeKey(stored.key),
};
store.files.set(fileKey(file.collectionID, file.id), file);
}
} catch {
// Any corruption discards the partial result: a half-read cache is
// worse than an empty one, since the refresh unit will rebuild it.
return new MetadataStore(path);
}
return store;
}
// Rewrite the whole file. The directory is created 0700 and the file left
// 0600; the write itself is the download layer's durable atomic writer
// (temp file, fsync, rename, dir fsync), so a reader never sees a partial
// file and a crash cannot leave a truncated one. There is no lock file and
// no `sync()` beyond the writer's own fsyncs.
async save(): Promise<void> {
const model: StoredMetadata = {
schemaVersion: METADATA_SCHEMA_VERSION,
userID: this.userID,
collectionsSinceTime: this.collectionsSinceTime,
collections: [...this.collections.values()].map((c) => ({
...c,
key: encodeKey(c.key),
})),
files: [...this.files.values()].map((f) => ({
...f,
key: encodeKey(f.key),
})),
};
const dir = dirname(this.path);
// chmod after mkdir so the mode is 0700 even when the directory
// already existed with a looser mode; mkdir alone would not tighten
// an existing directory.
await mkdir(dir, { recursive: true, mode: DIR_MODE });
await chmod(dir, DIR_MODE);
const payload = new TextEncoder().encode(
JSON.stringify(model, null, 2),
);
await writeAtomic(this.path, payload);
// The atomic writer's temp file inherits the default mode; tighten the
// renamed file to 0600. The 0700 directory already keeps other users
// out during the brief window before this runs.
await chmod(this.path, FILE_MODE);
}
getCollection(id: number): Collection | undefined {
return this.collections.get(id);
}
listCollections(): Collection[] {
return [...this.collections.values()];
}
putCollection(collection: Collection): void {
this.collections.set(collection.id, collection);
}
// Removing a collection also drops its file memberships: a file record is
// only meaningful as part of a collection the cache still knows about.
deleteCollection(id: number): void {
this.collections.delete(id);
for (const [key, file] of this.files) {
if (file.collectionID === id) {
this.files.delete(key);
}
}
}
getFile(collectionID: number, fileID: number): EnteFile | undefined {
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.
getFileByID(fileID: number): EnteFile | undefined {
for (const file of this.files.values()) {
if (file.id === fileID) return file;
}
return undefined;
}
listFiles(collectionID: number): EnteFile[] {
return [...this.files.values()].filter(
(f) => f.collectionID === collectionID,
);
}
putFile(file: EnteFile): void {
this.files.set(fileKey(file.collectionID, file.id), file);
}
deleteFile(collectionID: number, fileID: number): void {
this.files.delete(fileKey(collectionID, fileID));
}
}
+91 -105
View File
@@ -1,17 +1,11 @@
import { gunzipSync } from "node:zlib";
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import * as jpeg from "jpeg-js";
import exifReader from "exif-reader";
import type { Client } from "./client.js";
import { decryptBlob, fromBase64 } from "./crypto/index.js";
import type { Library, Photo } from "./library/index.js";
import { sanitizeFileName } from "./filename.js";
import { fetchMLData } from "./mldata-fetch.js";
import type { EnteFile } from "./model/types.js";
export type ProgressCallback = (message: string) => void;
@@ -21,89 +15,66 @@ export interface MetadataBackupOptions {
onProgress?: ProgressCallback;
}
const sanitizePath = (name: string): string =>
name.replace(/[/\\:*?"<>|]/g, "_").replace(/^\.+/, "_");
interface RawRemoteFileData {
fileID: number;
encryptedData: string;
decryptionHeader: string;
updatedAt?: number;
}
const fetchMLDataForFiles = async (
client: Client,
fileIDs: number[],
fileKeys: Map<number, Uint8Array>,
): Promise<Map<number, Record<string, unknown>>> => {
const api = client.getApiClient();
const result = new Map<number, Record<string, unknown>>();
const batchSize = 200;
for (let i = 0; i < fileIDs.length; i += batchSize) {
const batch = fileIDs.slice(i, i + batchSize);
const { data } = await api.postJSON<{ data: RawRemoteFileData[] }>(
"/files/data/fetch",
{ type: "mldata", fileIDs: batch },
);
for (const entry of data ?? []) {
const key = fileKeys.get(entry.fileID);
if (!key) continue;
try {
const decrypted = decryptBlob(
fromBase64(entry.encryptedData),
fromBase64(entry.decryptionHeader),
key,
);
const jsonStr = gunzipSync(Buffer.from(decrypted)).toString(
"utf-8",
);
result.set(entry.fileID, JSON.parse(jsonStr));
} catch {
// Corrupted ML data for this file; skip it
}
}
}
return result;
};
// Extract the raw EXIF APP1 segment from JPEG bytes. Returns the EXIF
// data buffer (starting after the APP1 length field, at the "Exif\0\0"
// header) or undefined if no APP1 marker is found.
const extractExifFromJpeg = (buf: Uint8Array): Buffer | undefined => {
if (buf[0] !== 0xff || buf[1] !== 0xd8) return undefined;
// 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 - 1) {
if (buf[offset] !== 0xff) return undefined;
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) break; // start of scan, no more markers
if (offset + 3 >= buf.length) break;
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
// 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 Buffer.from(
return {
exif: Buffer.from(
buf.buffer,
buf.byteOffset + offset + 4,
len - 2,
);
),
};
}
}
offset += 2 + len;
}
return undefined;
return { error: "file ends before the image data" };
};
const extractImageMetadata = (
// 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`.
export const extractImageMetadata = (
fileBytes: Uint8Array,
): Record<string, unknown> | undefined => {
try {
const result: Record<string, unknown> = {};
// Try to get dimensions from JPEG decode
@@ -116,15 +87,19 @@ const extractImageMetadata = (
result.width = decoded.width;
result.height = decoded.height;
} catch {
// Not a JPEG or corrupt; still try EXIF extraction
// 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
// reported below through `exifError`.
}
const exifBuf = extractExifFromJpeg(fileBytes);
if (exifBuf) {
const { exif, error } = extractExifFromJpeg(fileBytes);
if (error) result.exifError = error;
if (exif) {
try {
result.exif = exifReader(exifBuf);
} catch {
result.exifRaw = exifBuf.toString("base64");
result.exif = exifReader(exif);
} catch (err) {
result.exifRaw = exif.toString("base64");
result.exifError = err instanceof Error ? err.message : String(err);
}
}
@@ -144,29 +119,27 @@ const extractImageMetadata = (
}
return Object.keys(result).length > 0 ? result : undefined;
} catch {
return undefined;
}
};
// Read a file's original bytes through the library's content cache and extract
// its embedded image metadata. The bytes come from `photo.original()` — the
// same on-disk cache the rest of the library fills — rather than a fresh
// per-call download to a throwaway temp file.
const extractExif = async (
client: Client,
file: EnteFile,
photo: Photo,
): Promise<Record<string, unknown> | undefined> => {
const tmpDir = mkdtempSync(join(tmpdir(), "quak-exif-"));
try {
const origPath = join(tmpDir, "original");
await client.downloadFile(file, origPath);
const fileBytes = new Uint8Array(readFileSync(origPath));
const { path } = await photo.original();
const fileBytes = new Uint8Array(readFileSync(path));
return extractImageMetadata(fileBytes);
} catch {
return undefined;
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
};
// Dump every decrypted metadata layer the account holds into a directory tree
// 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 rather than a fresh server
// scan; the ML fetch and EXIF extraction are unchanged.
export const runMetadataBackup = async (
lib: Library,
client: Client,
outDir: string,
opts?: MetadataBackupOptions,
@@ -184,14 +157,20 @@ export const runMetadataBackup = async (
);
log("Fetching collections...");
const collections = await client.listCollections();
const allFiles: { file: EnteFile; colDirName: string }[] = [];
// Enumerate through the library's read surface. Each album carries its
// photos, but the full decrypted `Collection`/`EnteFile` records (with the
// magic-metadata layers this dump exists to preserve) come from the
// library's by-id accessors.
const allFiles: { file: EnteFile; photo: Photo; colDirName: string }[] = [];
const fileKeys = new Map<number, Uint8Array>();
const seenFileIDs = new Set<number>();
for (const col of collections) {
const dirName = `${col.id}-${sanitizePath(col.name || "unnamed")}`;
for (const album of lib.albums.list()) {
const col = lib.getCollection(album.collectionID);
if (!col) continue;
const dirName = `${col.id}-${sanitizeFileName(col.name, "unnamed")}`;
const colDir = join(outDir, "collections", dirName);
mkdirSync(colDir, { recursive: true });
@@ -215,11 +194,13 @@ export const runMetadataBackup = async (
);
log(`[${col.name}] Fetching files...`);
const files = await client.listFiles(col.id, col.key);
log(`[${col.name}] ${files.length} file(s)`);
const photos = album.photos.list();
log(`[${col.name}] ${photos.length} file(s)`);
for (const file of files) {
allFiles.push({ file, colDirName: dirName });
for (const photo of photos) {
const file = lib.getFile(col.id, photo.fileID);
if (!file) continue;
allFiles.push({ file, photo, colDirName: dirName });
if (!seenFileIDs.has(file.id)) {
fileKeys.set(file.id, file.key);
seenFileIDs.add(file.id);
@@ -228,15 +209,15 @@ export const runMetadataBackup = async (
}
log("Fetching ML data (face detections, CLIP embeddings)...");
const mlDataMap = await fetchMLDataForFiles(
client,
const mlDataMap = await fetchMLData(
client.getApiClient(),
[...fileKeys.keys()],
fileKeys,
);
log(`Got ML data for ${mlDataMap.size} file(s)`);
const writtenFileIDs = new Set<number>();
for (const { file, colDirName } of allFiles) {
for (const { file, photo, colDirName } of allFiles) {
const colDir = join(outDir, "collections", colDirName);
const fileMeta: Record<string, unknown> = {
@@ -255,8 +236,13 @@ export const runMetadataBackup = async (
if (wantExif && !writtenFileIDs.has(file.id)) {
log(`[${file.metadata.title}] Extracting EXIF...`);
const exifData = await extractExif(client, file);
try {
const exifData = await extractExif(photo);
if (exifData) fileMeta.imageMetadata = exifData;
} catch (err) {
fileMeta.imageMetadataError =
err instanceof Error ? err.message : String(err);
}
}
writtenFileIDs.add(file.id);
+93
View File
@@ -0,0 +1,93 @@
// Fetch and decrypt Ente's per-file machine-learning data ("magic" search
// data: face detections + CLIP embeddings).
//
// The data lives behind `/files/data/fetch` with `type: "mldata"`. Each entry
// comes back encrypted under the file's own key and gzipped; decrypting and
// gunzipping yields the JSON payload
// `{ face: { faces: [...] }, clip: { embedding } }`. Ente caps a request at 200
// ids, so `fetchMLData` batches for callers that want many at once while
// `fetchMLDataBatch` is the single-request unit the library submits to its
// request pool.
import { gunzipSync } from "node:zlib";
import type { ApiClient } from "./api/client.js";
import { decryptBlob, fromBase64 } from "./crypto/index.js";
// The most ids one `/files/data/fetch` request may carry.
export const MLDATA_BATCH_SIZE = 200;
// The decrypted, gunzipped per-file payload. Its concrete shape is Ente's; the
// store keeps the whole object verbatim and each consumer reads the fields it
// needs, so it stays an open record rather than a fixed interface.
export type MLData = Record<string, unknown>;
interface RawRemoteFileData {
fileID: number;
encryptedData: string;
decryptionHeader: string;
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;
}
};
// 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.
export const fetchMLDataBatch = async (
api: ApiClient,
fileIDs: number[],
fileKeys: Map<number, Uint8Array>,
): Promise<Map<number, MLData>> => {
const { data } = await api.postJSON<{ data: RawRemoteFileData[] }>(
"/files/data/fetch",
{ type: "mldata", fileIDs },
);
const result = new Map<number, MLData>();
for (const entry of data ?? []) {
const payload = decodeEntry(entry, fileKeys.get(entry.fileID));
if (payload) result.set(entry.fileID, payload);
}
return result;
};
// Fetch ML data for arbitrarily many ids, batching at `MLDATA_BATCH_SIZE`. Used
// by the one-shot metadata backup; the library fetches through its request pool
// with `fetchMLDataBatch` instead.
export const fetchMLData = async (
api: ApiClient,
fileIDs: number[],
fileKeys: Map<number, Uint8Array>,
): Promise<Map<number, MLData>> => {
const result = new Map<number, MLData>();
for (let i = 0; i < fileIDs.length; i += MLDATA_BATCH_SIZE) {
const batch = fileIDs.slice(i, i + MLDATA_BATCH_SIZE);
for (const [id, payload] of await fetchMLDataBatch(
api,
batch,
fileKeys,
)) {
result.set(id, payload);
}
}
return result;
};
+19 -3
View File
@@ -98,9 +98,18 @@ export const decryptFile = (
key,
);
const metadataJSON = JSON.parse(new TextDecoder().decode(metadataBytes));
if (
typeof metadataJSON !== "object" ||
metadataJSON === null ||
Array.isArray(metadataJSON)
) {
throw new Error(`file ${raw.id}: metadata is not a JSON object`);
}
const metadata: FileMetadata = {
title: metadataJSON.title ?? "",
// The server controls this JSON: a title that is missing or not a
// string becomes "", never an arbitrary value.
title: typeof metadataJSON.title === "string" ? metadataJSON.title : "",
fileType: parseFileType(metadataJSON.fileType ?? -1),
creationTime: metadataJSON.creationTime ?? 0,
modificationTime: metadataJSON.modificationTime ?? 0,
@@ -120,9 +129,16 @@ export const decryptFile = (
metadata,
magicMetadata,
pubMagicMetadata,
file: { decryptionHeader: raw.file.decryptionHeader },
thumbnail: { decryptionHeader: raw.thumbnail.decryptionHeader },
file: {
decryptionHeader: raw.file.decryptionHeader,
size: raw.info?.fileSize,
},
thumbnail: {
decryptionHeader: raw.thumbnail.decryptionHeader,
size: raw.info?.thumbSize,
},
updationTime: raw.updationTime,
isDeleted: raw.isDeleted,
};
};
+3
View File
@@ -48,6 +48,9 @@ export interface EnteFile {
file: FileBlob;
thumbnail: FileBlob;
updationTime: Microseconds;
// Set from the diff row's flag. Live files decode with it absent/false;
// deleted rows are filtered out before decryptFile, so it is not set here.
isDeleted?: boolean;
}
// The key material a logged-in client holds, everything needed to decrypt
+25 -12
View File
@@ -88,18 +88,21 @@ const MAX_CAUSE_DEPTH = 8;
// 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
// the top-level error would see a bare `Error` and call every dropped
// connection permanent.
const causeCodes = (err: unknown): string[] => {
// 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 codes: string[] = [];
let current: unknown = err;
for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) {
if (current === null || typeof current !== "object") break;
if (current === null || typeof current !== "object") {
return { codes, complete: true };
}
const { code, cause } = current as { code?: unknown; cause?: unknown };
if (typeof code === "string") codes.push(code);
if (cause === current) break;
if (cause === current) return { codes, complete: true };
current = cause;
}
return codes;
return { codes, complete: current === null || typeof current !== "object" };
};
const isAbort = (err: unknown): boolean => {
@@ -145,15 +148,15 @@ 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).some((code) => TRANSPORT_CODES.has(code));
return causeCodes(err).codes.some((code) => TRANSPORT_CODES.has(code));
};
// Could the first attempt already have taken effect on the server?
//
// `isRetryable` is the wrong question for a request that changes state.
// quak's non-idempotent calls are `/users/srp/create-session`,
// `/users/two-factor/verify` — which consumes one of a small number of 2FA
// attempts — and `/files/thumbnail`. They are replayed only on the failures in
// `postJSON` and `putJSON` use this for every `POST` and `PUT` listed in the
// README under "Endpoints used"; verifying a second factor, for one, consumes
// one of a small number of attempts. They are replayed only on the failures in
// `CONNECT_CODES`, which establish that no TCP connection to the server ever
// existed: there was no address to connect to, or the peer refused the
// connection outright. A request byte cannot have been transmitted, so the
@@ -162,9 +165,19 @@ 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.
export const isSafeToReplay = (err: unknown): boolean =>
isRetryable(err) && causeCodes(err).some((code) => CONNECT_CODES.has(code));
// 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.
export const isSafeToReplay = (err: unknown): boolean => {
const { codes, complete } = causeCodes(err);
return (
isRetryable(err) &&
complete &&
codes.length > 0 &&
codes.every((code) => CONNECT_CODES.has(code))
);
};
export interface WithRetryOptions extends RetryOptions {
isRetryable?: (err: unknown) => boolean;
+123 -66
View File
@@ -2,13 +2,10 @@ import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import * as jpeg from "jpeg-js";
import type { Client } from "./client.js";
import type { Library } from "./library/index.js";
import { ApiError } from "./api/client.js";
import { encryptBlob, toBase64 } from "./crypto/index.js";
import { downloadFile } from "./download/index.js";
import type { EnteFile } from "./model/types.js";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const THUMB_MAX_DIMENSION = 720;
const THUMB_JPEG_QUALITY = 50;
@@ -20,34 +17,51 @@ export interface MissingThumbnailInfo {
reason: string;
}
// Three outcomes, not two. "fixed": a thumbnail was generated and uploaded.
// "failed": something went wrong (download, encode, upload) and the file still
// has no thumbnail. "skipped": the file is a format this helper cannot
// regenerate — a video, or an image that is not a baseline JPEG. Skipped is a
// deliberate, expected outcome, not an error (issue #17): the repair path is
// JPEG-only because `jpeg-js` is, and a PNG or HEIC is left for a format-aware
// tool rather than reported as a failure.
export type ThumbnailFixStatus = "fixed" | "skipped" | "failed";
export interface ThumbnailFixResult {
fileID: number;
title: string;
collection: string;
success: boolean;
error?: string;
status: ThumbnailFixStatus;
// Why the file was skipped or failed; unset when it was fixed.
reason?: string;
}
export type ProgressCallback = (message: string) => 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
// only two answers: an empty body, or a 404. Any other error reaching this
// point has already exhausted its retries — a failing server, a dropped
// connection, a deadline — and says nothing about whether the thumbnail
// exists, so it is logged and the file is left unreported. That distinction is
// what stops `fix-missing-thumbnails` from regenerating and uploading over
// thumbnails that were fine all along while the CDN was briefly returning 500s.
export const listMissingThumbnails = async (
lib: Library,
client: Client,
onProgress?: ProgressCallback,
): Promise<MissingThumbnailInfo[]> => {
const log = onProgress ?? (() => {});
const api = client.getApiClient();
const missing: MissingThumbnailInfo[] = [];
const seen = new Set<number>();
const collections = await client.listCollections();
for (const col of collections) {
log(`[${col.name}] Checking thumbnails...`);
const files = await client.listFiles(col.id, col.key);
for (const file of files) {
if (seen.has(file.id)) continue;
seen.add(file.id);
for (const album of lib.albums.list()) {
log(`[${album.name}] Checking thumbnails...`);
for (const photo of album.photos.list()) {
if (seen.has(photo.fileID)) continue;
seen.add(photo.fileID);
try {
const api = client.getApiClient();
const stream = await api.getThumbnailStream(file.id);
const stream = await api.getThumbnailStream(photo.fileID);
const reader = stream.getReader();
let totalBytes = 0;
for (;;) {
@@ -57,35 +71,23 @@ export const listMissingThumbnails = async (
}
if (totalBytes === 0) {
missing.push({
fileID: file.id,
title: file.metadata.title,
collection: col.name,
fileID: photo.fileID,
title: photo.title,
collection: album.name,
reason: "empty thumbnail (0 bytes)",
});
}
} catch (err) {
// A 404 is the server stating the thumbnail is not there:
// that, and an empty body, are the only two answers that mean
// "missing". Anything else reaching this point is a failure
// that already exhausted its retries — a failing server, a
// dropped connection, a deadline — and says nothing about
// whether the thumbnail exists.
//
// The distinction is what stops `helper
// fix-missing-thumbnails` from downloading originals,
// regenerating thumbnails and uploading them over thumbnails
// that were fine all along, because the CDN was briefly
// returning 500s while this ran.
if (err instanceof ApiError && err.status === 404) {
missing.push({
fileID: file.id,
title: file.metadata.title,
collection: col.name,
fileID: photo.fileID,
title: photo.title,
collection: album.name,
reason: "thumbnail not found (HTTP 404)",
});
} else {
log(
`[${col.name}] Could not check ${file.metadata.title}: ${err instanceof Error ? err.message : String(err)} (not reported as missing)`,
`[${album.name}] Could not check ${photo.title}: ${err instanceof Error ? err.message : String(err)} (not reported as missing)`,
);
}
}
@@ -94,7 +96,7 @@ export const listMissingThumbnails = async (
return missing;
};
// Bilinear resize of RGBA pixel buffer
// Bilinear resize of an RGBA pixel buffer.
const resizeRGBA = (
src: Uint8Array,
srcW: number,
@@ -161,7 +163,33 @@ const generateThumbnail = (fileBytes: Uint8Array): Uint8Array => {
return new Uint8Array(encoded.data);
};
// A baseline/JFIF JPEG starts with the SOI marker 0xFFD8. `jpeg-js` decodes
// only JPEG, so this signature check is what separates a file the helper can
// regenerate from one it must skip: a PNG, HEIC, or the odd non-image byte
// stream all fail this and are reported as skipped rather than crashing the
// decoder into an opaque failure (issue #17).
const isJpeg = (bytes: Uint8Array): boolean =>
bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8;
// The reason a file cannot have a JPEG thumbnail regenerated for it from its
// metadata alone, before any bytes are fetched, or undefined when it might. A
// non-image (video, live photo) is unsupported outright; a still image still
// has to be checked against its actual bytes once downloaded.
const unsupportedByType = (file: EnteFile): string | undefined => {
if (file.metadata.fileType !== "image") {
return `unsupported file type: ${file.metadata.fileType} (only JPEG images can be regenerated)`;
}
return undefined;
};
// Regenerate and upload a thumbnail for each requested file. Originals are read
// through the library's content cache (`photo.original()`); the generated
// thumbnail is JPEG-encoded, encrypted under the file's own key, and registered
// with the server — the encrypt-and-upload path is unchanged. Each file is
// resolved to one outcome (fixed / skipped / failed) and a failure on one file
// never stops the others.
export const fixMissingThumbnails = async (
lib: Library,
client: Client,
fileIDs: number[],
onProgress?: ProgressCallback,
@@ -170,19 +198,24 @@ export const fixMissingThumbnails = async (
const results: ThumbnailFixResult[] = [];
const api = client.getApiClient();
const collections = await client.listCollections();
// Resolve each requested fileID to its file record and owning album by
// enumerating the library, each file taken from the first album that holds
// it. The raw `EnteFile` carries the per-file key the thumbnail is
// encrypted under, which the projected records deliberately do not.
const wanted = new Set(fileIDs);
const fileMap = new Map<
number,
{ file: EnteFile; collectionName: string }
>();
for (const col of collections) {
const files = await client.listFiles(col.id, col.key);
for (const file of files) {
if (fileIDs.includes(file.id) && !fileMap.has(file.id)) {
fileMap.set(file.id, {
for (const album of lib.albums.list()) {
for (const photo of album.photos.list()) {
if (!wanted.has(photo.fileID) || fileMap.has(photo.fileID))
continue;
const file = lib.getFile(album.collectionID, photo.fileID);
if (file) {
fileMap.set(photo.fileID, {
file,
collectionName: col.name,
collectionName: album.name,
});
}
}
@@ -195,33 +228,61 @@ export const fixMissingThumbnails = async (
fileID,
title: "unknown",
collection: "unknown",
success: false,
error: "file not found in any collection",
status: "failed",
reason: "file not found in any collection",
});
continue;
}
const { file, collectionName } = entry;
const tmpDir = mkdtempSync(join(tmpdir(), "quak-thumb-"));
const title = file.metadata.title;
const typeReason = unsupportedByType(file);
if (typeReason) {
log(`[${collectionName}] Skipping ${title}: ${typeReason}`);
results.push({
fileID,
title,
collection: collectionName,
status: "skipped",
reason: typeReason,
});
continue;
}
try {
log(
`[${collectionName}] Downloading ${file.metadata.title} for thumbnail generation...`,
);
const origPath = join(tmpDir, "original");
await downloadFile(api, file, origPath);
const photo = lib.photos.byID({ fileID });
if (!photo) {
throw new Error("file not present in the library cache");
}
log(
`[${collectionName}] Generating thumbnail for ${file.metadata.title}...`,
`[${collectionName}] Downloading ${title} for thumbnail generation...`,
);
const fileBytes = readFileSync(origPath);
const thumbJpeg = generateThumbnail(new Uint8Array(fileBytes));
const { path } = await photo.original();
const fileBytes = new Uint8Array(readFileSync(path));
if (!isJpeg(fileBytes)) {
const reason =
"unsupported image format (only baseline JPEG can be regenerated)";
log(`[${collectionName}] Skipping ${title}: ${reason}`);
results.push({
fileID,
title,
collection: collectionName,
status: "skipped",
reason,
});
continue;
}
log(`[${collectionName}] Generating thumbnail for ${title}...`);
const thumbJpeg = generateThumbnail(fileBytes);
log(
`[${collectionName}] Encrypting and uploading thumbnail (${thumbJpeg.length} bytes)...`,
);
const { header, ciphertext } = encryptBlob(thumbJpeg, file.key);
const md5 = createHash("md5").update(ciphertext).digest("base64");
const { objectKey, url } = await api.getUploadURL(
ciphertext.length,
@@ -230,28 +291,24 @@ export const fixMissingThumbnails = async (
await api.putFile(url, ciphertext);
await api.updateThumbnail(file.id, objectKey, toBase64(header));
log(
`[${collectionName}] Thumbnail uploaded for ${file.metadata.title}`,
);
log(`[${collectionName}] Thumbnail uploaded for ${title}`);
results.push({
fileID,
title: file.metadata.title,
title,
collection: collectionName,
success: true,
status: "fixed",
});
} catch (err) {
log(
`[${collectionName}] FAILED ${file.metadata.title}: ${err instanceof Error ? err.message : err}`,
`[${collectionName}] FAILED ${title}: ${err instanceof Error ? err.message : err}`,
);
results.push({
fileID,
title: file.metadata.title,
title,
collection: collectionName,
success: false,
error: err instanceof Error ? err.message : String(err),
status: "failed",
reason: err instanceof Error ? err.message : String(err),
});
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
}
+313 -14
View File
@@ -38,14 +38,18 @@
* the network. The fake records every call for assertion.
*/
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
ApiClient,
ApiError,
DEFAULT_DOWNLOAD_TIMEOUT_MS,
DEFAULT_REQUEST_TIMEOUT_MS,
} from "../../src/api/client.js";
import type { RetryOptions } from "../../src/retry.js";
import {
isRetryable,
isSafeToReplay,
type RetryOptions,
} from "../../src/retry.js";
// ---------------------------------------------------------------------------
// Test helpers
@@ -385,6 +389,103 @@ describe("ApiClient custom origins", () => {
});
});
describe("ApiClient request URLs", () => {
it("accepts a path with or without a leading slash", async () => {
const { fetch, calls } = recordingFetch(
jsonResponse({}),
jsonResponse({}),
jsonResponse({}),
);
const client = new ApiClient({ fetch });
await client.getJSON("health");
await client.postJSON("users/ott", {});
await client.putJSON("/files/thumbnail", {});
expect(calls.map((c) => c.url)).toEqual([
"https://api.ente.io/health",
"https://api.ente.io/users/ott",
"https://api.ente.io/files/thumbnail",
]);
});
it("accepts an apiOrigin with a trailing slash", async () => {
const { fetch, calls } = recordingFetch(jsonResponse({}));
const client = new ApiClient({
fetch,
apiOrigin: "https://my-ente.example.com/",
});
await client.getJSON("/health");
expect(calls[0]!.url).toBe("https://my-ente.example.com/health");
});
it("keeps a base path in a self-hosted apiOrigin for every request", async () => {
const body = new Uint8Array([1]);
const { fetch, calls } = recordingFetch(
jsonResponse({}),
jsonResponse({}),
jsonResponse({}),
streamResponse(body),
streamResponse(body),
);
const client = new ApiClient({
fetch,
apiOrigin: "https://example.com/ente/",
});
await client.getJSON("/collections/v2", { sinceTime: 0 });
await client.postJSON("/users/ott", {});
await client.putJSON("/files/thumbnail", {});
await client.getFileStream(99);
await client.getThumbnailStream(77);
expect(calls.map((c) => c.url)).toEqual([
"https://example.com/ente/collections/v2?sinceTime=0",
"https://example.com/ente/users/ott",
"https://example.com/ente/files/thumbnail",
"https://example.com/ente/files/download/99",
"https://example.com/ente/files/preview/77",
]);
});
it("percent-encodes query parameters and skips undefined ones", async () => {
const { fetch, calls } = recordingFetch(jsonResponse({}));
const client = new ApiClient({ fetch });
await client.getJSON("/search", {
q: "a&b=c/d é",
limit: 5,
cursor: undefined,
});
const url = new URL(calls[0]!.url);
expect(url.pathname).toBe("/search");
expect(url.search).toBe("?q=a%26b%3Dc%2Fd+%C3%A9&limit=5");
expect(url.searchParams.get("q")).toBe("a&b=c/d é");
});
it("rejects a path that carries its own query string", async () => {
const { fetch, calls } = recordingFetch();
const client = new ApiClient({ fetch });
await expect(client.getJSON("/diff?sinceTime=0")).rejects.toThrow(
/must not contain "\?"/,
);
await expect(client.postJSON("/users/ott?x=1", {})).rejects.toThrow(
/must not contain "\?"/,
);
expect(calls).toHaveLength(0);
});
it("rejects a path that carries a fragment", async () => {
const { fetch, calls } = recordingFetch();
const client = new ApiClient({ fetch });
await expect(client.getJSON("/diff#top")).rejects.toThrow(
/must not contain "\?" or "#"/,
);
expect(calls).toHaveLength(0);
});
});
describe("ApiError", () => {
it("throws ApiError on 4xx with status, code, requestID", async () => {
const { fetch } = recordingFetch(
@@ -639,17 +740,33 @@ describe("ApiClient retries", () => {
expect(policy.baseDelayMs).toBe(7);
expect(policy.maxDelayMs).toBe(11);
});
it("does not let a caller change its settings through that policy", async () => {
const { fetch, calls } = scriptedFetch(
textResponse("boom", 500),
textResponse("boom", 500),
textResponse("boom", 500),
);
const client = new ApiClient({
fetch,
retry: { ...noWait, attempts: 2 },
});
client.getRetryOptions().attempts = 3;
expect(client.getRetryOptions().attempts).toBe(2);
await expect(client.getJSON("/x")).rejects.toBeInstanceOf(ApiError);
expect(calls).toHaveLength(2);
});
});
describe("ApiClient timeouts", () => {
it("ships bounded default deadlines", () => {
// Asserted here so the README and the code cannot drift. Two numbers
// rather than one, because a deadline that is sane for a JSON call is
// nowhere near enough for a multi-gigabyte body, and a deadline long
// enough for that body would let a hung API call stall a backup for
// ten minutes.
// Asserted here so the README and the code cannot drift. The request
// deadline bounds a whole JSON call; the download deadline is an idle
// one, measured from the last byte that arrived.
expect(DEFAULT_REQUEST_TIMEOUT_MS).toBe(30_000);
expect(DEFAULT_DOWNLOAD_TIMEOUT_MS).toBe(600_000);
expect(DEFAULT_DOWNLOAD_TIMEOUT_MS).toBe(60_000);
});
it("attaches an abort signal to every request", async () => {
@@ -693,6 +810,51 @@ describe("ApiClient timeouts", () => {
expect(new Set(signals).size).toBe(3);
}, 5000);
it("gives every retrying entry point a fresh deadline per attempt", async () => {
// A refused connection is retried by every entry point, the
// non-idempotent ones included. If the deadline were created once,
// outside the retry, both attempts would carry the same signal.
const entryPoints: [
string,
() => Response,
(c: ApiClient) => unknown,
][] = [
["getJSON", () => jsonResponse({}), (c) => c.getJSON("/a")],
["postJSON", () => jsonResponse({}), (c) => c.postJSON("/b", {})],
["putJSON", () => jsonResponse({}), (c) => c.putJSON("/c", {})],
[
"putFile",
() => new Response(null, { status: 200 }),
(c) => c.putFile("https://s3.example/x", new Uint8Array([1])),
],
[
"getFileStream",
() => streamResponse(new Uint8Array([1])),
(c) => c.getFileStream(1),
],
[
"getThumbnailStream",
() => streamResponse(new Uint8Array([1])),
(c) => c.getThumbnailStream(1),
],
];
for (const [name, success, call] of entryPoints) {
const { fetch, calls } = scriptedFetch(
errnoError("ECONNREFUSED", "connect ECONNREFUSED"),
success(),
);
const client = new ApiClient({ fetch, retry: noWait });
await call(client);
expect(calls, name).toHaveLength(2);
const [first, second] = calls.map((c) => c.init?.signal);
expect(first, name).toBeInstanceOf(AbortSignal);
expect(second, name).toBeInstanceOf(AbortSignal);
expect(second, name).not.toBe(first);
}
});
it("recovers when a later attempt answers in time", async () => {
const { fetch, calls } = scriptedFetch(HANG, jsonResponse({ ok: 1 }));
const client = new ApiClient({
@@ -718,6 +880,11 @@ describe("ApiClient timeouts", () => {
// that never produces a chunk and never observes the signal, so the
// only thing that can unblock the read is quak's own enforcement of
// the deadline over the stream it hands out.
//
// The clock is faked, so the test runs under the real default
// deadline and waits for nothing.
vi.useFakeTimers();
try {
const stalling = new Response(
new ReadableStream<Uint8Array>({
pull: () => new Promise<void>(() => {}),
@@ -727,16 +894,77 @@ describe("ApiClient timeouts", () => {
const { fetch } = scriptedFetch(stalling);
const client = new ApiClient({
fetch,
downloadTimeoutMs: 20,
retry: { ...noWait, attempts: 1 },
});
const stream = await client.getFileStream(42);
const err: unknown = await readAll(stream).catch((e: unknown) => e);
let settled = false;
const result = readAll(stream).then(
(n) => n,
(e: unknown) => e,
);
void result.finally(() => {
settled = true;
});
await vi.advanceTimersByTimeAsync(DEFAULT_DOWNLOAD_TIMEOUT_MS - 1);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
const err = await result;
expect(err).toBeInstanceOf(Error);
expect((err as Error).name).toBe("TimeoutError");
}, 5000);
// Classified as every deadline is: retried by the idempotent
// downloads, never replayed for a POST or PUT.
expect(isRetryable(err)).toBe(true);
expect(isSafeToReplay(err)).toBe(false);
} finally {
vi.useRealTimers();
}
});
it("does not abort a slow body that keeps making progress", async () => {
// A deadline over the whole transfer would cut off a large video on a
// slow link however steadily it was arriving. The deadline restarts
// with every chunk, so a body that sends one byte every 600 ms for
// well over the 1000 ms deadline completes.
vi.useFakeTimers();
try {
let sent = 0;
const trickling = new Response(
new ReadableStream<Uint8Array>({
async pull(controller) {
await new Promise((resolve) =>
setTimeout(resolve, 600),
);
if (sent === 10) {
controller.close();
return;
}
controller.enqueue(new Uint8Array([sent++]));
},
}),
{ status: 200 },
);
const { fetch } = scriptedFetch(trickling);
const client = new ApiClient({
fetch,
downloadTimeoutMs: 1000,
retry: { ...noWait, attempts: 1 },
});
const stream = await client.getFileStream(42);
const result = readAll(stream).then(
(n) => n,
(e: unknown) => e,
);
await vi.advanceTimersByTimeAsync(11 * 600);
expect(await result).toBe(10);
} finally {
vi.useRealTimers();
}
});
it("lets a body that arrives in time through untouched", async () => {
// The counterpart to the previous test: enforcing the deadline over
@@ -762,6 +990,52 @@ describe("ApiClient timeouts", () => {
}
expect(joined).toEqual(payload);
});
it("leaves no timer pending after a download completes or fails", async () => {
vi.useFakeTimers();
try {
const { fetch } = scriptedFetch(
streamResponse(new Uint8Array([1, 2, 3])),
textResponse("gone", 404),
);
const client = new ApiClient({
fetch,
retry: { ...noWait, attempts: 1 },
});
expect(await readAll(await client.getFileStream(1))).toBe(3);
expect(vi.getTimerCount()).toBe(0);
await expect(client.getFileStream(2)).rejects.toBeInstanceOf(
ApiError,
);
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
});
it("never lets the download timer keep the process alive", async () => {
const spy = vi.spyOn(globalThis, "setTimeout");
try {
const { fetch } = scriptedFetch(
streamResponse(new Uint8Array([1])),
);
const client = new ApiClient({
fetch,
downloadTimeoutMs: 12_345,
retry: noWait,
});
const stream = await client.getFileStream(1);
const i = spy.mock.calls.findIndex((call) => call[1] === 12_345);
const timer = spy.mock.results[i]!.value as NodeJS.Timeout;
expect(timer.hasRef()).toBe(false);
await stream.cancel();
} finally {
spy.mockRestore();
}
});
});
describe("ApiClient error typing", () => {
@@ -820,9 +1094,8 @@ describe("ApiClient error typing", () => {
describe("ApiClient non-idempotent requests", () => {
/**
* `postJSON` and `putJSON` carry quak's only requests that change server
* state: `/users/srp/create-session`, `/users/two-factor/verify` — which
* consumes one of a small number of 2FA attempts — and `/files/thumbnail`.
* `postJSON` and `putJSON` carry quak's requests that can change server
* state; the README lists them under "Endpoints used".
*
* They are retried only on a failure that establishes no TCP connection to
* the server ever existed — DNS produced no address, or the peer refused
@@ -922,4 +1195,30 @@ describe("ApiClient non-idempotent requests", () => {
await refusedClient.updateThumbnail(1, "key", "header");
expect(refused.calls).toHaveLength(2);
});
it("does not follow or replay a redirect on POST or PUT", async () => {
// The origin has already received a request it answers with a
// redirect, so following it would let a refused connection to the
// redirect target pass for a request that never went out.
for (const send of [
(c: ApiClient) => c.postJSON("/users/ott", {}),
(c: ApiClient) => c.putJSON("/files/thumbnail", {}),
]) {
const { fetch, calls } = scriptedFetch(
new Response(null, {
status: 307,
headers: { location: "https://elsewhere.example/" },
}),
jsonResponse({}),
);
const client = new ApiClient({ fetch, retry: noWait });
const err: unknown = await send(client).catch((e: unknown) => e);
expect(calls[0]?.init?.redirect).toBe("manual");
expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).status).toBe(307);
expect(calls).toHaveLength(1);
}
});
});
+531 -376
View File
@@ -1,368 +1,260 @@
/**
* Tests for the `quak backup` command's core logic.
* Tests for the `quak backup` logic, now built on the library API (issue #51).
*
* `quak backup <dir>` downloads every file from every collection into
* a local directory tree:
* `lib.backup({ downloadDirectory })` refreshes the library, fetches each
* pending file's original through the content cache/pools, and materialises the
* unchanged on-disk layout:
*
* <dir>/
* <collection-name>/
* <file-title>
* <file-title>
* <collection-name>/
* ...
* metadata.json (all decrypted collection + file metadata)
* <downloadDirectory>/
* originals/
* <fileID>.<ext> the decrypted bytes ("present means complete")
* <fileID>.json per-file metadata sidecar (rebuilt each run)
* collections/
* <name>/<title> symlink into ../originals (rebuilt each run)
* <name>.json per-collection metadata (rebuilt each run)
* failures.json durable ledger of unresolved failures
*
* The backup command has two properties that distinguish it from a naive
* "download everything" loop:
* The properties that distinguish backup from a naive download loop, and that
* these tests lock down:
*
* 1. **Skip existing files.** If `<dir>/<collection>/<title>` already
* exists on disk and its size matches the decrypted content length
* recorded in metadata.json from a prior run, the file is not
* re-downloaded. This makes interrupted backups resumable and
* incremental runs fast.
* 1. Present-means-complete: an original already on disk is not re-fetched, so
* runs are idempotent and interrupted runs resume.
* 2. Per-file resilience: a download failure or a symlink failure is recorded
* and the run continues (issue #8); the derived symlink/JSON views are
* rebuilt from the model every run.
* 3. A durable `failures.json` records each unresolved failure's classification,
* attempt count, and last-tried time; the exit code (result.failed) is
* non-zero while any failure remains and clears once every one is resolved.
*
* 2. **Never crash on a single file failure.** If a file download or
* decryption fails, the error is logged and the backup continues
* with the next file. At the end, the exit code is non-zero if any
* files failed, and the summary lists them. The Ente first-party
* CLI crashes entirely when a single file can't be retrieved,
* which defeats the purpose of a backup tool.
*
* These tests exercise the backup logic (in src/backup.ts) using the
* same mock server from the Client usage tests. The CLI binary itself
* is a thin wrapper around this module.
* The cache and download layers are covered elsewhere (content.test.ts,
* download tests); here a mock library client and a stand-in content source
* drive the backup logic with no crypto and no network.
*/
import {
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
readlinkSync,
rmSync,
writeFileSync,
} from "node:fs";
import { spawnSync } from "node:child_process";
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 } from "vitest";
import {
init,
toBase64,
deriveKEK,
deriveLoginSubkey,
} from "../../src/crypto/index.js";
import { Client } from "../../src/client.js";
import { runBackup } from "../../src/backup.js";
import type { KeyAttributes } from "../../src/auth/types.js";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// ---------------------------------------------------------------------------
// Mock server (condensed from usage.test.ts)
// ---------------------------------------------------------------------------
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 { Collection, EnteFile } from "../../src/model/types.js";
const TEST_EMAIL = "backup@example.com";
const TEST_PASSWORD = "backuppass";
const TEST_OPS = 2;
const TEST_MEM = 64 * 1024 * 1024;
// `open` and `rename` are wrapped to record, in order, every fsync and rename,
// so a test can pin the sequence "fsync the temp file, rename, fsync the
// directory" that makes a copied original survive a power cut. `vi.hoisted`
// because `vi.mock` factories run before module-level constants exist.
const fsEvents = vi.hoisted(() => [] as string[]);
interface MockState {
verifier: Buffer;
srpAttributes: Record<string, unknown>;
keyAttributes: KeyAttributes;
encryptedToken: string;
collections: Record<string, unknown>[];
files: Record<
number,
{ raw: Record<string, unknown>; plaintext: Uint8Array }
>;
}
let mock: MockState;
let testDir: string;
const buildMock = async (): Promise<MockState> => {
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 = "backup-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 keyNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const encryptedKey = sodium.crypto_secretbox_easy(masterKey, keyNonce, kek);
const kp = sodium.crypto_box_keypair();
const skNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const encSK = sodium.crypto_secretbox_easy(
kp.privateKey,
skNonce,
masterKey,
);
const tokenBytes = sodium.randombytes_buf(32);
const encToken = sodium.crypto_box_seal(tokenBytes, kp.publicKey);
const keyAttributes: KeyAttributes = {
kekSalt: toBase64(kekSalt),
encryptedKey: toBase64(encryptedKey),
keyDecryptionNonce: toBase64(keyNonce),
publicKey: toBase64(kp.publicKey),
encryptedSecretKey: toBase64(encSK),
secretKeyDecryptionNonce: toBase64(skNonce),
memLimit: TEST_MEM,
opsLimit: TEST_OPS,
};
const makeCollection = (id: number, name: string) => {
const ck = sodium.crypto_secretbox_keygen();
const ckN = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const encCK = sodium.crypto_secretbox_easy(ck, ckN, masterKey);
const nameBytes = new TextEncoder().encode(name);
const cnN = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const encCN = sodium.crypto_secretbox_easy(nameBytes, cnN, ck);
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
raw: {
...actual,
open: async (
...args: Parameters<typeof actual.open>
): Promise<Awaited<ReturnType<typeof actual.open>>> => {
const handle = await actual.open(...args);
const realSync = handle.sync.bind(handle);
handle.sync = async (): Promise<void> => {
fsEvents.push(`sync:${String(args[0])}`);
await realSync();
};
return handle;
},
rename: async (from: string, to: string): Promise<void> => {
fsEvents.push(`rename:${to}`);
await actual.rename(from, to);
},
};
});
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 };
const collection = (id: number, name: string): Collection => ({
id,
owner: { id: 42 },
encryptedKey: toBase64(encCK),
keyDecryptionNonce: toBase64(ckN),
encryptedName: toBase64(encCN),
nameDecryptionNonce: toBase64(cnN),
ownerID: USER_ID,
key: new Uint8Array([id & 0xff]),
name,
type: "album",
updationTime: 1700000000000000,
},
key: ck,
};
};
const makeFile = (
id: number,
collKey: Uint8Array,
title: string,
plaintext: Uint8Array,
collID: number,
) => {
const fk = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const fkN = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const encFK = sodium.crypto_secretbox_easy(fk, fkN, collKey);
const meta = JSON.stringify({
title,
fileType: 0,
creationTime: 1700000000000000,
modificationTime: 1700000000000000,
updationTime: 1,
isShared: false,
});
const metaPush =
sodium.crypto_secretstream_xchacha20poly1305_init_push(fk);
const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push(
metaPush.state,
new TextEncoder().encode(meta),
null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
);
const filePush =
sodium.crypto_secretstream_xchacha20poly1305_init_push(fk);
const encFile = sodium.crypto_secretstream_xchacha20poly1305_push(
filePush.state,
plaintext,
null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
);
return {
raw: {
const file = (id: number, collectionID: number, title: string): EnteFile => ({
id,
collectionID: collID,
ownerID: 42,
encryptedKey: toBase64(encFK),
keyDecryptionNonce: toBase64(fkN),
collectionID,
ownerID: USER_ID,
key: new Uint8Array([id & 0xff]),
metadata: {
encryptedData: toBase64(encMeta),
decryptionHeader: toBase64(metaPush.header),
title,
fileType: "image",
creationTime: 1,
modificationTime: 1,
},
file: { decryptionHeader: toBase64(filePush.header) },
thumbnail: {
decryptionHeader: toBase64(sodium.randombytes_buf(24)),
},
updationTime: 1700000000000000,
},
plaintext,
ciphertext: encFile,
};
};
const col1 = makeCollection(1, "Vacation");
const col2 = makeCollection(2, "Work");
const file1 = makeFile(
100,
col1.key,
"beach.jpg",
sodium.randombytes_buf(3000),
1,
);
const file2 = makeFile(
101,
col1.key,
"sunset.jpg",
sodium.randombytes_buf(2000),
1,
);
const file3 = makeFile(
200,
col2.key,
"diagram.png",
sodium.randombytes_buf(1500),
2,
);
file: { decryptionHeader: "aGVhZGVy" },
thumbnail: { decryptionHeader: "dGh1bWI=" },
updationTime: 1,
});
// A metadata-only client: two albums, three files, served once. No ML.
class MockClient {
private served = false;
whoami(): { email: string; userID: number } {
return { email: "backup@example.com", userID: USER_ID };
}
async collectionsSince(): Promise<CollectionsPage> {
if (this.served) return { collections: [], deleted: [], cursor: 1 };
this.served = true;
return {
verifier,
srpAttributes: {
srpUserID,
srpSalt: toBase64(srpSalt),
memLimit: TEST_MEM,
opsLimit: TEST_OPS,
kekSalt: toBase64(kekSalt),
isEmailMFAEnabled: false,
collections: [collection(1, "Vacation"), collection(2, "Work")],
deleted: [],
cursor: 1,
};
}
async filesSince(args: { collectionID: number }): Promise<FilesPage> {
const files =
args.collectionID === 1
? [file(100, 1, "beach.jpg"), file(101, 1, "sunset.jpg")]
: args.collectionID === 2
? [file(200, 2, "diagram.png")]
: [];
return { files, deleted: [], cursor: 1 };
}
}
// A server that names an album and a file so as to climb out of the backup
// directory.
class HostileClient extends MockClient {
override async collectionsSince(): Promise<CollectionsPage> {
const page = await super.collectionsSince();
return {
...page,
collections: page.collections.length
? [collection(3, "../escape")]
: [],
};
}
override async filesSince(args: {
collectionID: number;
}): Promise<FilesPage> {
const files =
args.collectionID === 3
? [file(300, 3, "../../.ssh/authorized_keys")]
: [];
return { files, deleted: [], cursor: 1 };
}
}
// 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.
interface StubSource extends ContentSource {
failID?: number;
failThumbID?: number;
originalCalls: number;
}
const stubSource = (): StubSource => {
const s: StubSource = {
originalCalls: 0,
original: async ({ file: f, destination }) => {
s.originalCalls++;
if (s.failID === f.id) throw new Error("HTTP 500 from server");
const size = SIZE_BY_ID[f.id] ?? 10;
writeFileSync(destination, Buffer.alloc(size));
return { bytesWritten: size };
},
keyAttributes,
encryptedToken: toBase64(encToken),
collections: [col1.raw, col2.raw],
thumbnail: async ({ file: f, destination }) => {
if (s.failThumbID === f.id) throw new Error("HTTP 500 from server");
writeFileSync(destination, Buffer.alloc(5));
return { bytesWritten: 5 };
},
};
return s;
};
let root: string;
const openLibrary = (
source: ContentSource,
client: MockClient = new MockClient(),
): Promise<Library> =>
Library.open({
client,
cacheDirectory: join(root, "cache"),
contentSource: source,
refreshIntervalSeconds: 3600,
// These tests count exact fetches; the background precache (#48) would
// add its own, so it is off here (it is covered in precache.test.ts).
precacheThumbnails: false,
precacheOriginals: false,
});
const readLedger = (
outDir: string,
): { files: Record<string, Record<string, unknown>> } =>
JSON.parse(readFileSync(join(outDir, "failures.json"), "utf-8"));
// Write a durable ledger holding one prior failure, to exercise pruning of
// entries the current run cannot resolve.
const seedLedger = (outDir: string, fileID: number, title: string): void => {
mkdirSync(outDir, { recursive: true });
writeFileSync(
join(outDir, "failures.json"),
JSON.stringify({
version: 1,
files: {
1: {
raw: [file1.raw, file2.raw],
ciphertexts: { 100: file1.ciphertext, 101: file2.ciphertext },
[String(fileID)]: {
fileID,
title,
classification: "transient",
attempts: 1,
lastTriedAt: Date.now(),
error: "HTTP 500 from server",
},
2: { raw: [file3.raw], ciphertexts: { 200: file3.ciphertext } },
100: { plaintext: file1.plaintext },
101: { plaintext: file2.plaintext },
200: { plaintext: file3.plaintext },
} as Record<number, unknown>,
};
};
const buildMockFetch = (m: MockState, opts?: { failFileID?: 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 parsed = new URL(url);
const path = parsed.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(parsed.searchParams.get("collectionID"));
const collData = m.files[collID] as { raw: unknown[] } | undefined;
return json({ diff: collData?.raw ?? [], hasMore: false });
}
// File download
if (url.includes("fileID=") || path.startsWith("/files/download/")) {
const fileID = Number(
parsed.searchParams.get("fileID") ?? path.split("/").pop(),
);
if (opts?.failFileID === fileID) {
return new Response("Internal Server Error", { status: 500 });
}
const collData = Object.values(m.files).find(
(v: unknown) =>
v &&
typeof v === "object" &&
"ciphertexts" in (v as Record<string, unknown>) &&
fileID in
(v as Record<string, Record<number, unknown>>)
.ciphertexts,
) as { ciphertexts: Record<number, Uint8Array> } | undefined;
if (collData) {
return new Response(collData.ciphertexts[fileID], {
status: 200,
});
}
return new Response("not found", { status: 404 });
}
return new Response("not found", { status: 404 });
}) as typeof globalThis.fetch;
};
// ---------------------------------------------------------------------------
// Setup / teardown
// ---------------------------------------------------------------------------
beforeAll(async () => {
await init();
await sodium.ready;
mock = await buildMock();
testDir = mkdtempSync(join(tmpdir(), "quak-backup-test-"));
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "quak-backup-test-"));
});
afterAll(() => {
if (testDir && existsSync(testDir))
rmSync(testDir, { recursive: true, force: true });
afterEach(() => {
if (root && existsSync(root))
rmSync(root, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("quak backup", () => {
it("downloads all files organized by collection name", async () => {
const outDir = join(testDir, "full-backup");
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMockFetch(mock) },
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);
expect(source.originalCalls).toBe(0);
lib.close();
});
const result = await runBackup(client, outDir);
it("writes the expected on-disk layout for every file", async () => {
const source = stubSource();
const lib = await openLibrary(source);
const outDir = join(root, "backup");
const result = await lib.backup({ downloadDirectory: outDir });
expect(result.totalFiles).toBe(3);
expect(result.downloaded).toBe(3);
@@ -370,7 +262,7 @@ describe("quak backup", () => {
expect(result.failed).toBe(0);
expect(result.errors).toEqual([]);
// Originals are under <outDir>/originals/<fileID>.<ext>
// Originals under originals/<fileID>.<ext>.
expect(readFileSync(join(outDir, "originals", "100.jpg")).length).toBe(
3000,
);
@@ -381,55 +273,81 @@ describe("quak backup", () => {
1500,
);
// Collection dirs under collections/ contain symlinks to originals
const beachLink = join(outDir, "collections", "Vacation", "beach.jpg");
expect(lstatSync(beachLink).isSymbolicLink()).toBe(true);
expect(readlinkSync(beachLink)).toContain("originals");
expect(readFileSync(beachLink).length).toBe(3000);
// Per-file metadata sidecar.
const sidecar = JSON.parse(
readFileSync(join(outDir, "originals", "100.json"), "utf-8"),
);
expect(sidecar.id).toBe(100);
expect(sidecar.metadata.title).toBe("beach.jpg");
// Collection dirs contain symlinks into ../originals.
const beach = join(outDir, "collections", "Vacation", "beach.jpg");
expect(lstatSync(beach).isSymbolicLink()).toBe(true);
expect(readlinkSync(beach)).toContain("originals");
expect(readFileSync(beach).length).toBe(3000);
// Per-collection metadata JSON.
const vacation = JSON.parse(
readFileSync(join(outDir, "collections", "Vacation.json"), "utf-8"),
);
expect(vacation.name).toBe("Vacation");
expect(vacation.files.length).toBe(2);
expect(vacation.files[0].metadata.title).toBeDefined();
// A clean run leaves no failure ledger behind.
expect(existsSync(join(outDir, "failures.json"))).toBe(false);
lib.close();
});
it("skips files that already exist on disk with matching size", async () => {
const outDir = join(testDir, "incremental");
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMockFetch(mock) },
it("keeps server-supplied album and file names inside the backup", async () => {
const lib = await openLibrary(stubSource(), new HostileClient());
const outDir = join(root, "backup");
const result = await lib.backup({ downloadDirectory: outDir });
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);
const link = join(
outDir,
"collections",
"__escape",
"__.._.ssh_authorized_keys",
);
expect(lstatSync(link).isSymbolicLink()).toBe(true);
expect(existsSync(join(outDir, "collections", "__escape.json"))).toBe(
true,
);
// Nothing landed beside or above the backup directory.
expect(readdirSync(root).sort()).toEqual(["backup", "cache"]);
lib.close();
});
// First run: download everything
const first = await runBackup(client, outDir);
it("is an idempotent no-op when every original is already present", async () => {
const source = stubSource();
const lib = await openLibrary(source);
const outDir = join(root, "backup");
const first = await lib.backup({ downloadDirectory: outDir });
expect(first.downloaded).toBe(3);
const callsAfterFirst = source.originalCalls;
// Second run: everything should be skipped
const second = await runBackup(client, outDir);
const second = await lib.backup({ downloadDirectory: outDir });
expect(second.downloaded).toBe(0);
expect(second.skipped).toBe(3);
expect(second.failed).toBe(0);
// A present original is neither fetched nor copied again.
expect(source.originalCalls).toBe(callsAfterFirst);
lib.close();
});
it("continues after a single file download failure", async () => {
// File 101 (sunset.jpg) will return HTTP 500. The other two
// files must still download. The result must report the failure
// without throwing.
//
// A 500 is retryable, so this file now costs several requests before
// it is given up on — that is the point of the retry policy, and
// `runBackup`'s own resilience is unchanged by it: the retry lives
// strictly below this loop, and an exhausted file is still logged,
// counted, and stepped over rather than aborting the run. The
// injected `sleep` is what keeps the suite from actually waiting out
// the backoff.
const outDir = join(testDir, "partial-failure");
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: {
fetch: buildMockFetch(mock, { failFileID: 101 }),
retry: { sleep: () => Promise.resolve(), random: () => 0 },
},
});
it("continues past a download failure and records it in failures.json", async () => {
const source = stubSource();
source.failID = 101;
const lib = await openLibrary(source);
const outDir = join(root, "backup");
const result = await runBackup(client, outDir);
const result = await lib.backup({ downloadDirectory: outDir });
expect(result.totalFiles).toBe(3);
expect(result.downloaded).toBe(2);
@@ -438,32 +356,269 @@ describe("quak backup", () => {
expect(result.errors[0]!.fileID).toBe(101);
expect(result.errors[0]!.title).toBe("sunset.jpg");
// The two successful originals are on disk
// 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);
// The failed file has no original and no symlink
expect(existsSync(join(outDir, "originals", "101.jpg"))).toBe(false);
expect(
existsSync(join(outDir, "collections", "Vacation", "sunset.jpg")),
).toBe(false);
// Durable ledger with classification, attempts, last-tried.
const ledger = readLedger(outDir);
const entry = ledger.files["101"]!;
expect(entry.attempts).toBe(1);
expect(entry.classification).toBeDefined();
expect(typeof entry.lastTriedAt).toBe("number");
lib.close();
});
it("writes per-collection JSON metadata", async () => {
const outDir = join(testDir, "metadata-check");
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMockFetch(mock) },
it("increments the attempt count across runs and clears the ledger once resolved", async () => {
const source = stubSource();
source.failID = 101;
const lib = await openLibrary(source);
const outDir = join(root, "backup");
const r1 = await lib.backup({ downloadDirectory: outDir });
expect(r1.failed).toBe(1);
expect(readLedger(outDir).files["101"]!.attempts).toBe(1);
// Second run: the two good files are present, only 101 is retried.
const r2 = await lib.backup({ downloadDirectory: outDir });
expect(r2.failed).toBe(1);
expect(r2.skipped).toBe(2);
expect(readLedger(outDir).files["101"]!.attempts).toBe(2);
// Resume with a healthy source: 101 downloads, the rest are skipped.
source.failID = undefined;
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);
// A ledger with no remaining failures is removed.
expect(existsSync(join(outDir, "failures.json"))).toBe(false);
lib.close();
});
await runBackup(client, outDir);
it("does not abort when a symlink cannot be created (issue #8)", async () => {
const source = stubSource();
const lib = await openLibrary(source);
const outDir = join(root, "backup");
// Each collection gets a <name>.json next to its image dir
const vacationMeta = join(outDir, "collections", "Vacation.json");
expect(existsSync(vacationMeta)).toBe(true);
const meta = JSON.parse(readFileSync(vacationMeta, "utf-8"));
expect(meta.name).toBe("Vacation");
expect(meta.files.length).toBeGreaterThan(0);
expect(meta.files[0].metadata.title).toBeDefined();
// Occupy beach.jpg's symlink path with a directory so symlink creation
// fails for that one file.
mkdirSync(join(outDir, "collections", "Vacation", "beach.jpg"), {
recursive: true,
});
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);
// The other symlinks are still built.
expect(
lstatSync(
join(outDir, "collections", "Vacation", "sunset.jpg"),
).isSymbolicLink(),
).toBe(true);
expect(
lstatSync(
join(outDir, "collections", "Work", "diagram.png"),
).isSymbolicLink(),
).toBe(true);
// The symlink failure is recorded, not thrown.
expect(result.failed).toBeGreaterThanOrEqual(1);
const err = result.errors.find((e) => e.fileID === 100);
expect(err).toBeDefined();
expect(err!.collection).toBe("Vacation");
expect(readLedger(outDir).files["100"]).toBeDefined();
lib.close();
});
it("rebuilds a stale sidecar and a missing symlink on a later run", async () => {
const source = stubSource();
const lib = await openLibrary(source);
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
// Corrupt a sidecar and delete a symlink between runs.
writeFileSync(join(outDir, "originals", "100.json"), "not json");
rmSync(join(outDir, "collections", "Vacation", "beach.jpg"));
const result = await lib.backup({ downloadDirectory: outDir });
expect(result.failed).toBe(0);
// The derived views are repaired from the model.
const sidecar = JSON.parse(
readFileSync(join(outDir, "originals", "100.json"), "utf-8"),
);
expect(sidecar.metadata.title).toBe("beach.jpg");
expect(
lstatSync(
join(outDir, "collections", "Vacation", "beach.jpg"),
).isSymbolicLink(),
).toBe(true);
lib.close();
});
it("backs up only the named albums when onlyAlbumNames is given", async () => {
const source = stubSource();
const lib = await openLibrary(source);
const outDir = join(root, "backup");
const result = await lib.backup({
downloadDirectory: outDir,
onlyAlbumNames: ["Work"],
});
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(join(outDir, "collections", "Work.json"))).toBe(true);
expect(existsSync(join(outDir, "collections", "Vacation.json"))).toBe(
false,
);
lib.close();
});
it("prunes a ledger entry for a file no longer in the library and exits zero", async () => {
const source = stubSource();
const lib = await openLibrary(source);
const outDir = join(root, "backup");
// A prior failure for a file that has since left the library (deleted
// from the account). This run has no way to resolve it, so it must not
// keep the exit code non-zero forever.
seedLedger(outDir, 999, "gone.jpg");
const result = await lib.backup({ downloadDirectory: outDir });
// Everything still present is backed up cleanly, and the stale entry is
// dropped rather than counted.
expect(result.downloaded).toBe(3);
expect(result.failed).toBe(0);
expect(existsSync(join(outDir, "failures.json"))).toBe(false);
lib.close();
});
it("prunes an out-of-scope ledger entry on a scoped run and exits zero", async () => {
const source = stubSource();
const lib = await openLibrary(source);
const outDir = join(root, "backup");
// A prior failure for a Vacation file; this run is scoped to Work and
// never attempts it, so it must not poison the scoped run's exit code.
seedLedger(outDir, 100, "beach.jpg");
const result = await lib.backup({
downloadDirectory: outDir,
onlyAlbumNames: ["Work"],
});
expect(result.totalFiles).toBe(1);
expect(result.failed).toBe(0);
expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true);
expect(existsSync(join(outDir, "failures.json"))).toBe(false);
lib.close();
});
it("also stores thumbnails when includeThumbnails is set", async () => {
const source = stubSource();
const lib = await openLibrary(source);
const outDir = join(root, "backup");
await lib.backup({
downloadDirectory: outDir,
includeThumbnails: true,
});
expect(existsSync(join(outDir, "thumbnails", "100.jpg"))).toBe(true);
expect(existsSync(join(outDir, "thumbnails", "200.jpg"))).toBe(true);
lib.close();
});
it("counts one attempt when a file fails both its original and thumbnail in a run", async () => {
const source = stubSource();
source.failID = 101;
source.failThumbID = 101;
const lib = await openLibrary(source);
const outDir = join(root, "backup");
const result = await lib.backup({
downloadDirectory: outDir,
includeThumbnails: true,
});
// Both kinds fail for 101, but the run counts it once.
const errs = result.errors.filter((e) => e.fileID === 101);
expect(errs.length).toBe(1);
expect(readLedger(outDir).files["101"]!.attempts).toBe(1);
lib.close();
});
it("fsyncs a copied original 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");
fsEvents.length = 0;
await lib.backup({ downloadDirectory: outDir });
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$/,
);
expect(fsEvents[at + 1]).toBe(`sync:${originals}`);
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 });
// 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`;
// 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 lib = await openLibrary(stubSource());
await lib.backup({ downloadDirectory: outDir });
const names = readdirSync(originals);
expect(names).not.toContain(leftover);
expect(names).toContain(inProgress);
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");
mkdirSync(thumbnails, { recursive: true });
const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid;
const leftover = `.quak-backup-100.jpg-${exitedPID}-abc123.tmp`;
const inProgress = `.quak-backup-101.jpg-${process.pid}-def456.tmp`;
writeFileSync(join(thumbnails, leftover), "partial");
writeFileSync(join(thumbnails, inProgress), "partial");
const lib = await openLibrary(stubSource());
await lib.backup({
downloadDirectory: outDir,
includeThumbnails: true,
});
const names = readdirSync(thumbnails);
expect(names).not.toContain(leftover);
expect(names).toContain(inProgress);
lib.close();
});
});
+397
View File
@@ -0,0 +1,397 @@
/**
* Tests for the CLI commands (`src/cli-commands.ts`, issue #12).
*
* Each command is called directly with a context whose output streams collect
* text, whose session directory is a fresh temp directory, and whose session
* loader hands back a fake client. The fake serves two albums and three files
* from memory, writes stand-in bytes for originals and thumbnails, and makes no
* network calls. The helpers the commands call (`cli-read`, `cli-output`,
* backup, thumbnails) have their own tests; these check what each command
* prints and the exit code it returns.
*/
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import {
type CliContext,
saveSession,
whoamiCommand,
logoutCommand,
collectionsCommand,
filesCommand,
getCommand,
getThumbCommand,
backupCommand,
listMissingThumbnailsCommand,
} from "../../src/cli-commands.js";
import { loadSession } from "../../src/cli-session.js";
import type { Client, ClientSnapshot } from "../../src/client.js";
import type { ContentSource } from "../../src/library/content.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
import { init } from "../../src/crypto/index.js";
const USER_ID = 42;
const collection = (
id: number,
name: string,
isShared = false,
): Collection => ({
id,
ownerID: USER_ID,
key: new Uint8Array([id]),
name,
type: "album",
updationTime: 1,
isShared,
});
const file = (id: number, collectionID: number, title: string): EnteFile => ({
id,
collectionID,
ownerID: USER_ID,
key: new Uint8Array([id & 0xff]),
metadata: {
title,
fileType: "image",
creationTime: 1000,
modificationTime: 1000,
},
file: { decryptionHeader: "aGVhZGVy" },
thumbnail: { decryptionHeader: "dGh1bWI=" },
updationTime: 1,
});
const COLLECTIONS = [collection(1, "Vacation"), collection(2, "Work", true)];
const FILES: Record<number, EnteFile[]> = {
1: [file(100, 1, "beach.jpg"), file(101, 1, "sunset.jpg")],
2: [file(200, 2, "diagram.png")],
};
// 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.
const fakeClient = (opts: { failID?: number; emptyThumbID?: number } = {}) => {
const source: ContentSource = {
original: async ({ file: f, destination }) => {
if (f.id === opts.failID) throw new Error("HTTP 500 from server");
writeFileSync(destination, Buffer.alloc(7, f.id & 0xff));
return { bytesWritten: 7 };
},
thumbnail: async ({ file: f, destination }) => {
writeFileSync(destination, Buffer.alloc(3, f.id & 0xff));
return { bytesWritten: 3 };
},
};
const fake = {
whoami: () => ({ email: "cli@example.com", userID: USER_ID }),
collectionsSince: async () => ({
collections: COLLECTIONS,
deleted: [],
cursor: 1,
}),
filesSince: async (args: { collectionID: number }) => ({
files: FILES[args.collectionID] ?? [],
deleted: [],
cursor: 1,
}),
contentSource: () => source,
getApiClient: () => ({
getThumbnailStream: async (fileID: number) =>
new ReadableStream<Uint8Array>({
start(controller) {
if (fileID !== opts.emptyThumbID) {
controller.enqueue(new Uint8Array(3));
}
controller.close();
},
}),
}),
};
// The commands only call the methods above.
return fake as unknown as Client;
};
// Collects everything written to it.
class Output {
text = "";
write(text: string): void {
this.text += text;
}
}
let root: string;
let stdout: Output;
let stderr: Output;
const context = (client: Client | null = fakeClient()): CliContext => ({
stdout,
stderr,
sessionDir: join(root, "session"),
cacheDir: join(root, "cache"),
loadSession: () => client,
});
beforeAll(async () => {
await init();
});
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "quak-cli-test-"));
stdout = new Output();
stderr = new Output();
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
describe("session file", () => {
const snapshot: ClientSnapshot = {
email: "cli@example.com",
userID: USER_ID,
token: "token",
masterKey: "a",
secretKey: "b",
publicKey: "c",
};
it("is written with mode 0600 in a directory with mode 0700", () => {
const dir = join(root, "new", "session");
saveSession(dir, snapshot);
expect(statSync(dir).mode & 0o777).toBe(0o700);
const path = join(dir, "session.json");
expect(statSync(path).mode & 0o777).toBe(0o600);
expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(snapshot);
});
it("is removed by logout", async () => {
const ctx = context();
saveSession(ctx.sessionDir, snapshot);
expect(await logoutCommand(ctx)).toBe(0);
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false);
expect(stderr.text).toBe("Session deleted.\n");
});
it("logout without a session says so and exits 0", async () => {
expect(await logoutCommand(context())).toBe(0);
expect(stderr.text).toBe("No session found.\n");
});
it("a missing session exits 1 with 'Not logged in'", async () => {
const ctx = { ...context(), loadSession };
expect(await whoamiCommand(ctx)).toBe(1);
expect(stderr.text).toBe(
`Not logged in. Run "quak login" first.\n` +
`Session file: ${join(ctx.sessionDir, "session.json")}\n`,
);
expect(stdout.text).toBe("");
});
it("a corrupt session exits 1 and says it is corrupt", async () => {
const ctx = { ...context(), loadSession };
saveSession(ctx.sessionDir, snapshot);
expect(await collectionsCommand(ctx, {})).toBe(1);
expect(stderr.text).toContain("is corrupt");
expect(stderr.text).toContain(
`Run "quak logout" and then "quak login" to replace it.\n`,
);
expect(stdout.text).toBe("");
});
});
describe("whoami", () => {
it("prints the account as one line of JSON", async () => {
expect(await whoamiCommand(context())).toBe(0);
expect(stdout.text).toBe(
`{"email":"cli@example.com","userID":${USER_ID}}\n`,
);
});
});
describe("collections", () => {
it("prints one tab-separated line per album", async () => {
expect(await collectionsCommand(context(), {})).toBe(0);
expect(stdout.text).toBe(
"1\talbum\tVacation\n" + "2\talbum\tWork (shared)\n",
);
});
it("prints a JSON array with --json", async () => {
expect(await collectionsCommand(context(), { json: true })).toBe(0);
expect(JSON.parse(stdout.text)).toEqual([
{
id: 1,
name: "Vacation",
type: "album",
ownerID: USER_ID,
isShared: false,
updationTime: 1,
},
{
id: 2,
name: "Work",
type: "album",
ownerID: USER_ID,
isShared: true,
updationTime: 1,
},
]);
});
});
describe("files", () => {
it("prints one tab-separated line per file", async () => {
expect(await filesCommand(context(), { collection: "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,
});
expect(code).toBe(0);
expect(JSON.parse(stdout.text)).toEqual([
{
id: 200,
title: "diagram.png",
fileType: "image",
creationTime: 1000,
collectionID: 2,
},
]);
});
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");
});
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", () => {
it("get finds a file in any album without --collection", async () => {
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`);
});
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`);
});
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(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(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");
});
});
describe("backup", () => {
it("exits 0 and prints a summary when every file is saved", async () => {
const dir = join(root, "backup");
expect(await backupCommand(context(), dir, {})).toBe(0);
expect(stderr.text).toContain(
"\n--- Backup complete ---\n" +
" Total files: 3\n" +
" Downloaded: 3\n" +
" Skipped: 0\n" +
" Failed: 0\n",
);
expect(stdout.text).toBe("");
});
it("exits 1 and lists the file when one download fails", async () => {
const ctx = context(fakeClient({ failID: 101 }));
expect(await backupCommand(ctx, join(root, "backup"), {})).toBe(1);
expect(stderr.text).toContain(" Failed: 1\n");
expect(stderr.text).toContain(
"\nFailed files:\n" +
" [Vacation] sunset.jpg (id 101): HTTP 500 from server\n",
);
});
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"), {
json: true,
});
expect(code).toBe(1);
const result = JSON.parse(stdout.text);
expect(result).toMatchObject({
totalFiles: 3,
downloaded: 2,
skipped: 0,
failed: 1,
});
expect(result.errors[0].fileID).toBe(101);
expect(stderr.text).toBe("Starting backup...\n");
});
});
describe("helper list-missing-thumbnails", () => {
it("prints one line per file with an empty thumbnail", async () => {
const ctx = context(fakeClient({ emptyThumbID: 200 }));
expect(await listMissingThumbnailsCommand(ctx, {})).toBe(0);
expect(stdout.text).toBe(
"200\tdiagram.png\tWork\tempty thumbnail (0 bytes)\n",
);
expect(stderr.text).toContain("\n1 file(s) with missing thumbnails:\n");
});
it("says so when nothing is missing", async () => {
expect(await listMissingThumbnailsCommand(context(), {})).toBe(0);
expect(stdout.text).toBe("");
expect(stderr.text).toContain("No missing thumbnails found.\n");
});
it("prints a JSON array with --json and no progress", async () => {
const ctx = context(fakeClient({ emptyThumbID: 200 }));
expect(await listMissingThumbnailsCommand(ctx, { json: true })).toBe(0);
expect(JSON.parse(stdout.text)).toEqual([
{
fileID: 200,
title: "diagram.png",
collection: "Work",
reason: "empty thumbnail (0 bytes)",
},
]);
expect(stderr.text).toBe("");
});
});
+79 -64
View File
@@ -2,13 +2,17 @@
* Tests for `quak backup-metadata <dir>`.
*
* This command dumps all decrypted account metadata into a directory
* tree of plain JSON files, without downloading any file content. It
* is fast (no multi-megabyte downloads) and produces a complete
* plaintext record of every collection name, file title, creation
* date, GPS coordinate, camera model, caption, face label, and any
* other metadata the Ente clients have attached.
* tree of plain JSON files, without downloading any file content (unless
* `--exif` is given). It is fast and produces a complete plaintext record of
* every collection name, file title, creation date, GPS coordinate, camera
* model, caption, face label, and any other metadata the Ente clients have
* attached.
*
* Layout:
* As of issue #52 it runs on the library API: `runMetadataBackup(lib, client,
* dir)` enumerates collections and files from the library's cache rather than
* scanning the client directly, and `--exif` reads each original through the
* library's content cache (`photo.original()`). The ML fetch is unchanged. The
* output tree is identical:
*
* <dir>/
* account.json { email, userID }
@@ -44,7 +48,11 @@ import {
} from "../../src/crypto/index.js";
import * as jpegJs from "jpeg-js";
import { Client } from "../../src/client.js";
import { runMetadataBackup } from "../../src/metadata-backup.js";
import { Library } from "../../src/library/index.js";
import {
runMetadataBackup,
type MetadataBackupOptions,
} from "../../src/metadata-backup.js";
import type { KeyAttributes } from "../../src/auth/types.js";
const TEST_EMAIL = "metabackup@example.com";
@@ -157,14 +165,15 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
},
};
// Collection 2: "Work" with no magic metadata
// Collection 2: "../Work" with no magic metadata. The server chose a name
// that tries to climb out of the backup directory.
const ck2 = sodium.crypto_secretbox_keygen();
const { ciphertext: encCK2, nonce: ck2N } = encryptSecretbox(
ck2,
masterKey,
);
const { ciphertext: encCN2, nonce: cn2N } = encryptSecretbox(
new TextEncoder().encode("Work"),
new TextEncoder().encode("../Work"),
ck2,
);
const rawColl2 = {
@@ -431,16 +440,50 @@ afterAll(() => {
rmSync(testDir, { recursive: true, force: true });
});
describe("quak backup-metadata", () => {
it("writes account.json with email and userID", async () => {
const outDir = join(testDir, "full");
// Log in against the mock and open a library over its cache. The point commands
// open the library with the background precache off and a long refresh interval;
// the same here keeps the test deterministic (no thumbnail/original prefetch it
// did not ask for, no second refresh mid-test).
const openLib = async (client: Client): Promise<Library> =>
Library.open({
// The library client omits `fetchMLData`, matching how the CLI opens
// point commands: `runMetadataBackup` fetches ML data itself through
// the client, so the library's background backfill would only be a
// redundant second pass over the same endpoint.
client: {
whoami: () => client.whoami(),
collectionsSince: (args) => client.collectionsSince(args),
filesSince: (args) => client.filesSince(args),
contentSource: () => client.contentSource(),
},
cacheDirectory: mkdtempSync(join(testDir, "cache-")),
refreshIntervalSeconds: 3600,
precacheThumbnails: false,
precacheOriginals: false,
});
// Run one metadata backup end to end: fresh client, fresh library, then close.
const runBackup = async (
outDir: string,
opts?: MetadataBackupOptions,
): Promise<void> => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMetaFetch(mock) },
});
const lib = await openLib(client);
try {
await runMetadataBackup(lib, client, outDir, opts);
} finally {
lib.close();
}
};
await runMetadataBackup(client, outDir);
describe("quak backup-metadata", () => {
it("writes account.json with email and userID", async () => {
const outDir = join(testDir, "full");
await runBackup(outDir);
const account = JSON.parse(
readFileSync(join(outDir, "account.json"), "utf-8"),
@@ -451,16 +494,11 @@ describe("quak backup-metadata", () => {
it("creates per-collection directories with _collection.json", async () => {
const outDir = join(testDir, "collections");
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMetaFetch(mock) },
});
await runMetadataBackup(client, outDir);
await runBackup(outDir);
const collDirs = readdirSync(join(outDir, "collections"));
expect(collDirs.length).toBe(2);
// "../Work" is sanitized into one directory name.
expect(collDirs.sort()).toEqual(["10-Vacation", "20-__Work"]);
// Find the Vacation collection dir (prefixed with ID)
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
@@ -478,13 +516,7 @@ describe("quak backup-metadata", () => {
it("decrypts collection-level pubMagicMetadata", async () => {
const outDir = join(testDir, "coll-magic");
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMetaFetch(mock) },
});
await runMetadataBackup(client, outDir);
await runBackup(outDir);
const collDirs = readdirSync(join(outDir, "collections"));
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
@@ -501,13 +533,7 @@ describe("quak backup-metadata", () => {
it("writes per-file JSON with all three metadata layers", async () => {
const outDir = join(testDir, "file-meta");
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMetaFetch(mock) },
});
await runMetadataBackup(client, outDir);
await runBackup(outDir);
const collDirs = readdirSync(join(outDir, "collections"));
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
@@ -526,13 +552,7 @@ describe("quak backup-metadata", () => {
it("handles files with no magic metadata gracefully", async () => {
const outDir = join(testDir, "no-magic");
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMetaFetch(mock) },
});
await runMetadataBackup(client, outDir);
await runBackup(outDir);
const collDirs = readdirSync(join(outDir, "collections"));
const workDir = collDirs.find((d) => d.includes("Work"))!;
@@ -550,14 +570,8 @@ describe("quak backup-metadata", () => {
it("is incremental: second run does not fail", async () => {
const outDir = join(testDir, "incremental");
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMetaFetch(mock) },
});
await runMetadataBackup(client, outDir);
await runMetadataBackup(client, outDir);
await runBackup(outDir);
await runBackup(outDir);
const account = JSON.parse(
readFileSync(join(outDir, "account.json"), "utf-8"),
@@ -567,13 +581,7 @@ describe("quak backup-metadata", () => {
it("fetches and decrypts ML data by default", async () => {
const outDir = join(testDir, "ml-data");
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMetaFetch(mock) },
});
await runMetadataBackup(client, outDir);
await runBackup(outDir);
const collDirs = readdirSync(join(outDir, "collections"));
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
@@ -597,13 +605,7 @@ describe("quak backup-metadata", () => {
it("extracts EXIF from downloaded files when --exif is set", async () => {
const outDir = join(testDir, "exif-data");
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMetaFetch(mock) },
});
await runMetadataBackup(client, outDir, { exif: true });
await runBackup(outDir, { exif: true });
const collDirs = readdirSync(join(outDir, "collections"));
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
@@ -619,5 +621,18 @@ describe("quak backup-metadata", () => {
expect(fileMeta.imageMetadata.format).toBe("jpeg");
expect(fileMeta.imageMetadata.width).toBe(100);
expect(fileMeta.imageMetadata.height).toBe(80);
expect(fileMeta.imageMetadataError).toBeUndefined();
// File 200 has no original on the mock server, so extraction fails
// and the reason is recorded instead of the field being left out.
const workDir = collDirs.find((d) => d.includes("Work"))!;
const failedMeta = JSON.parse(
readFileSync(
join(outDir, "collections", workDir, "200.json"),
"utf-8",
),
);
expect(failedMeta.imageMetadata).toBeUndefined();
expect(failedMeta.imageMetadataError).toEqual(expect.any(String));
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* Tests for the JPEG EXIF scan behind `quak backup-metadata --exif`.
*
* 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.
*/
import { describe, expect, it } from "vitest";
import {
extractExifFromJpeg,
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
const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0"
// A big-endian TIFF block with one IFD entry: Orientation (0x0112), SHORT, 6.
const TIFF_ORIENTATION_6 = [
0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08, 0x00, 0x01, 0x01, 0x12,
0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 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;
return [0xff, 0xe1, len >> 8, len & 0xff, ...data];
};
const bytes = (...parts: number[][]): Uint8Array =>
new Uint8Array(parts.flat());
describe("extractExifFromJpeg", () => {
it("returns the EXIF segment 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);
});
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("returns nothing for a JPEG without EXIF", () => {
const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
expect(extractExifFromJpeg(bytes(SOI, app0, SOS))).toEqual({});
});
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("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", () => {
// APP1 claims 0x4000 bytes but only the "Exif\0\0" header follows.
const scan = extractExifFromJpeg(
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
);
expect(scan.exif).toBeUndefined();
expect(scan.error).toMatch(/runs past the end of the file/);
});
});
describe("extractImageMetadata", () => {
it("parses EXIF from a valid JPEG", () => {
const meta = extractImageMetadata(
bytes(SOI, app1([...EXIF_HEADER, ...TIFF_ORIENTATION_6]), SOS),
);
expect(meta?.exifError).toBeUndefined();
expect(meta?.exif).toMatchObject({ Image: { Orientation: 6 } });
});
it("returns nothing for a file that is not a JPEG", () => {
const text = new TextEncoder().encode("just some text, not an image");
expect(extractImageMetadata(text)).toBeUndefined();
});
it("records the reason when the JPEG is malformed", () => {
const meta = extractImageMetadata(
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
);
expect(meta?.exif).toBeUndefined();
expect(meta?.exifError).toMatch(/runs past the end of the file/);
});
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));
expect(meta?.exif).toBeUndefined();
expect(meta?.exifRaw).toBe(Buffer.from(data).toString("base64"));
expect(meta?.exifError).toEqual(expect.any(String));
});
});
+94
View File
@@ -0,0 +1,94 @@
// The CLI presents a file by its own decrypted metadata, not the PhotoRecord
// projection (issue #52). For a renamed file the two disagree: the projection
// prefers `editedName` and reports `editedTime` in milliseconds, while the CLI
// must print the raw `metadata.title` and `metadata.creationTime` (microseconds)
// and name downloads after the raw title, byte-identical to the pre-library CLI.
//
// This locks in that contrast: the shared output helpers emit the raw values,
// and the projection of the same file emits the edited ones — so a regression
// that re-sourced the CLI from the projection would fail here.
import { describe, it, expect } from "vitest";
import {
fileListRow,
fileListLine,
originalName,
thumbnailName,
} from "../../src/cli-output.js";
import { deriveRecords } from "../../src/library/records.js";
import type { EnteFile } from "../../src/model/types.js";
// Microseconds, as Ente stores times.
const RAW_CREATION = 1700000000000000;
const EDITED_TIME = 1710000000000000;
const RAW_TITLE = "IMG_0001.HEIC";
const EDITED_NAME = "Sunset.heic";
// A file the user has renamed and re-dated: basic metadata holds the original
// title and capture time; public magic metadata holds the edits.
const renamedFile: EnteFile = {
id: 100,
collectionID: 10,
ownerID: 42,
key: new Uint8Array(),
metadata: {
title: RAW_TITLE,
fileType: "image",
creationTime: RAW_CREATION,
modificationTime: RAW_CREATION,
},
pubMagicMetadata: { editedName: EDITED_NAME, editedTime: EDITED_TIME },
file: { decryptionHeader: "" },
thumbnail: { decryptionHeader: "" },
updationTime: RAW_CREATION,
};
describe("CLI file output (issue #52)", () => {
it("emits the raw title and microsecond creationTime for --json", () => {
expect(fileListRow(renamedFile)).toEqual({
id: 100,
title: RAW_TITLE,
fileType: "image",
creationTime: RAW_CREATION,
collectionID: 10,
});
});
it("emits the raw title in the human column", () => {
expect(fileListLine(renamedFile)).toBe(`100\timage\t${RAW_TITLE}`);
});
it("names downloads after the raw title", () => {
expect(originalName(renamedFile)).toBe(RAW_TITLE);
expect(thumbnailName(renamedFile)).toBe(`thumb_${RAW_TITLE}`);
});
it("sanitizes the title when naming `quak get` downloads", () => {
// Without `--out`, the server-supplied title names the file, so it must
// not be able to point outside the working directory.
const hostile = {
...renamedFile,
metadata: { ...renamedFile.metadata, title: "../../.bashrc" },
};
expect(originalName(hostile)).toBe("__.._.bashrc");
expect(thumbnailName(hostile)).toBe("thumb___.._.bashrc");
const untitled = {
...renamedFile,
metadata: { ...renamedFile.metadata, title: "" },
};
expect(originalName(untitled)).toBe("file-100");
expect(thumbnailName(untitled)).toBe("thumb_file-100");
});
it("does not use the editedName/editedTime projection", () => {
const record = deriveRecords([], [renamedFile]).photos.get(100);
// The projection prefers the edits and reports milliseconds; the CLI
// helpers above deliberately do not.
expect(record?.title).toBe(EDITED_NAME);
expect(record?.takenAt).toBe(Math.floor(EDITED_TIME / 1000));
expect(fileListRow(renamedFile).title).not.toBe(record?.title);
expect(fileListRow(renamedFile).creationTime).not.toBe(record?.takenAt);
});
});
+195
View File
@@ -0,0 +1,195 @@
/**
* Tests for the CLI read helpers (`src/cli-read.ts`, owner amendment to
* 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.
*
* `collections` and `files` also list in the library's enumeration order
* (`listCollections`/`listFiles`) — the order the pre-library CLI printed — not
* the albums/photos projection's newest-first order. The fixtures are seeded in
* an enumeration order that a newest-first sort would rearrange, so a
* regression to the projection order would fail here too. Field values still
* come from the raw metadata via `cli-output.ts`.
*/
import { describe, it, expect } from "vitest";
import {
freshCollections,
freshFiles,
freshFile,
type FreshReadLibrary,
} from "../../src/cli-read.js";
import { fileListRow } from "../../src/cli-output.js";
import type { Photo } from "../../src/library/index.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
const collection = (id: number, updationTime: number): Collection => ({
id,
ownerID: 42,
key: new Uint8Array(),
name: `album-${id}`,
type: "album",
updationTime,
isShared: false,
});
// Microseconds, as Ente stores times.
const file = (
id: number,
collectionID: number,
creationTime: number,
): EnteFile => ({
id,
collectionID,
ownerID: 42,
key: new Uint8Array(),
metadata: {
title: `file-${id}.jpg`,
fileType: "image",
creationTime,
modificationTime: creationTime,
},
file: { decryptionHeader: "" },
thumbnail: { decryptionHeader: "" },
updationTime: creationTime,
});
// 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.
class FakeLibrary implements FreshReadLibrary {
freshCalls = 0;
private refreshed = false;
constructor(
private readonly collections: Collection[],
private readonly files: EnteFile[],
) {}
async fresh(): Promise<unknown> {
this.freshCalls++;
this.refreshed = true;
return {};
}
listCollections(): Collection[] {
return this.refreshed ? this.collections : [];
}
getCollection(id: number): Collection | undefined {
return this.listCollections().find((c) => c.id === id);
}
listFiles(collectionID: number): EnteFile[] {
return this.refreshed
? this.files.filter((f) => f.collectionID === collectionID)
: [];
}
getFileByID(fileID: number): EnteFile | undefined {
if (!this.refreshed) return undefined;
return this.files.find((f) => f.id === fileID);
}
photos = {
byID: ({ fileID }: { fileID: number }): Photo | undefined => {
if (!this.refreshed) return undefined;
if (!this.files.some((f) => f.id === fileID)) return undefined;
return { fileID } as unknown as Photo;
},
};
}
describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
it("freshCollections refreshes first, then lists 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)],
[],
);
const rows = await freshCollections(lib);
expect(lib.freshCalls).toBe(1);
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.
const newestFirst = [...rows]
.sort((a, b) => b.updationTime - a.updationTime)
.map((c) => c.id);
expect(newestFirst).toEqual([1, 2, 3]);
expect(rows.map((c) => c.id)).not.toEqual(newestFirst);
});
it("freshFiles refreshes first, lists in enumeration order, keeps raw fields", async () => {
// Enumeration order by id 10, 11, 12; creationTimes ascending, so a
// newest-first sort would reverse them.
const files = [
file(10, 1, 1_700_000_000_000_000),
file(11, 1, 1_700_000_000_000_001),
file(12, 1, 1_700_000_000_000_002),
];
const lib = new FakeLibrary([collection(1, 100)], files);
const rows = await freshFiles(lib, 1);
expect(lib.freshCalls).toBe(1);
expect(rows?.map((f) => f.id)).toEqual([10, 11, 12]);
// Field values come from raw metadata: microsecond creationTime and the
// raw title, unchanged.
expect(rows?.map(fileListRow)).toEqual([
{
id: 10,
title: "file-10.jpg",
fileType: "image",
creationTime: 1_700_000_000_000_000,
collectionID: 1,
},
{
id: 11,
title: "file-11.jpg",
fileType: "image",
creationTime: 1_700_000_000_000_001,
collectionID: 1,
},
{
id: 12,
title: "file-12.jpg",
fileType: "image",
creationTime: 1_700_000_000_000_002,
collectionID: 1,
},
]);
});
it("freshFiles returns undefined for an unknown collection", async () => {
const lib = new FakeLibrary([collection(1, 100)], []);
const rows = await freshFiles(lib, 999);
expect(lib.freshCalls).toBe(1);
expect(rows).toBeUndefined();
});
it("freshFile refreshes first, then resolves the photo and its raw record", async () => {
const f = file(10, 1, 1_700_000_000_000_000);
const lib = new FakeLibrary([collection(1, 100)], [f]);
const resolved = await freshFile(lib, 10);
expect(lib.freshCalls).toBe(1);
expect(resolved?.photo.fileID).toBe(10);
expect(resolved?.file.metadata.title).toBe("file-10.jpg");
expect(resolved?.file.metadata.creationTime).toBe(
1_700_000_000_000_000,
);
});
it("freshFile returns undefined for an unknown file", async () => {
const lib = new FakeLibrary([collection(1, 100)], []);
const resolved = await freshFile(lib, 404);
expect(lib.freshCalls).toBe(1);
expect(resolved).toBeUndefined();
});
});
+367
View File
@@ -0,0 +1,367 @@
/**
* Tests for the resumable, deletion-aware enumeration variants on `Client`:
* `collectionsSince` and `filesSince`.
*
* The whole-account methods `listCollections` / `listFiles` always start at
* `sinceTime: 0` and hide deletions. The cache refresh needs the opposite:
* start from a saved cursor, learn what was deleted, and get back a cursor to
* resume from next time. These two methods provide that.
*
* 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`.
*
* 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
* with libsodium exactly as the server would encrypt them; tombstone rows carry
* only the fields the code reads (`id`, `updationTime`, `isDeleted`), because
* they are never decrypted.
*/
import sodium from "libsodium-wrappers-sumo";
import { beforeAll, describe, expect, it } from "vitest";
import { init, toBase64 } from "../../src/crypto/index.js";
import { Client, type ClientSnapshot } from "../../src/client.js";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const USER_ID = 42;
interface Keys {
masterKey: Uint8Array;
publicKey: Uint8Array;
secretKey: Uint8Array;
}
const buildKeys = (): Keys => {
const kp = sodium.crypto_box_keypair();
return {
masterKey: sodium.crypto_secretbox_keygen(),
publicKey: kp.publicKey,
secretKey: kp.privateKey,
};
};
const snapshotFor = (keys: Keys): ClientSnapshot => ({
email: "user@example.com",
userID: USER_ID,
token: "test-token",
masterKey: toBase64(keys.masterKey),
secretKey: toBase64(keys.secretKey),
publicKey: toBase64(keys.publicKey),
});
const secretboxEncrypt = (
plaintext: Uint8Array,
key: Uint8Array,
): { ciphertext: Uint8Array; nonce: Uint8Array } => {
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
return {
ciphertext: sodium.crypto_secretbox_easy(plaintext, nonce, key),
nonce,
};
};
/** An owned collection row as the server sends it, keyed under the master key. */
const ownedCollectionRow = (
masterKey: Uint8Array,
opts: { id: number; name: string; updationTime: number },
): Record<string, unknown> => {
const collectionKey = sodium.crypto_secretbox_keygen();
const { ciphertext: encKey, nonce: keyNonce } = secretboxEncrypt(
collectionKey,
masterKey,
);
const { ciphertext: encName, nonce: nameNonce } = secretboxEncrypt(
new TextEncoder().encode(opts.name),
collectionKey,
);
return {
id: opts.id,
owner: { id: USER_ID },
encryptedKey: toBase64(encKey),
keyDecryptionNonce: toBase64(keyNonce),
encryptedName: toBase64(encName),
nameDecryptionNonce: toBase64(nameNonce),
type: "album",
updationTime: opts.updationTime,
};
};
/** A live file row inside a collection, keyed under that collection's key. */
const fileRow = (
collectionKey: Uint8Array,
opts: { id: number; title: string; updationTime: number },
): 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 push =
sodium.crypto_secretstream_xchacha20poly1305_init_push(fileKey);
const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push(
push.state,
new TextEncoder().encode(JSON.stringify(metadata)),
null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
);
return {
id: opts.id,
collectionID: 1,
ownerID: USER_ID,
encryptedKey: toBase64(encFileKey),
keyDecryptionNonce: toBase64(fileKeyNonce),
metadata: {
encryptedData: toBase64(encMeta),
decryptionHeader: toBase64(push.header),
},
file: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
thumbnail: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
updationTime: opts.updationTime,
};
};
/** A tombstone row. Never decrypted, so only these fields are ever read. */
const tombstoneRow = (
id: number,
updationTime: number,
): Record<string, unknown> => ({
id,
updationTime,
isDeleted: true,
});
const jsonResponse = (body: unknown): Response =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
/**
* A fetch that serves canned responses in order and records the `sinceTime`
* query parameter each request carried, so tests can prove the cursor is
* threaded from one page (and one call) to the next.
*/
const recordingFetch = (
...responses: Response[]
): { fetch: typeof globalThis.fetch; sinceTimes: (string | null)[] } => {
const sinceTimes: (string | null)[] = [];
let i = 0;
const fake = async (input: RequestInfo | URL): Promise<Response> => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
sinceTimes.push(new URL(url).searchParams.get("sinceTime"));
if (i >= responses.length) {
throw new Error(`recordingFetch: no response for call #${i}`);
}
return responses[i++]!;
};
return { fetch: fake as typeof globalThis.fetch, sinceTimes };
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("Client.filesSince", () => {
beforeAll(async () => {
await init();
await sodium.ready;
});
it("pages from the given cursor, decrypts live rows, and collects tombstones", async () => {
const keys = buildKeys();
const collectionKey = sodium.crypto_secretbox_keygen();
// Page 1 mixes a live file and a tombstone; the tombstone has the
// higher updationTime, so it — not the live row — sets the cursor the
// second page must be fetched from.
const { fetch, sinceTimes } = recordingFetch(
jsonResponse({
diff: [
fileRow(collectionKey, {
id: 1001,
title: "first.jpg",
updationTime: 100,
}),
tombstoneRow(1002, 150),
],
hasMore: true,
}),
jsonResponse({
diff: [
fileRow(collectionKey, {
id: 1003,
title: "second.jpg",
updationTime: 200,
}),
],
hasMore: false,
}),
);
const client = Client.fromJSON(snapshotFor(keys), { fetch });
const { files, deleted, cursor } = await client.filesSince({
collectionID: 1,
collectionKey,
sinceTime: 0,
});
expect(files.map((f) => f.id)).toEqual([1001, 1003]);
expect(files.map((f) => f.metadata.title)).toEqual([
"first.jpg",
"second.jpg",
]);
expect(deleted).toEqual([1002]);
expect(cursor).toBe(200);
// First request started at the caller's cursor; the second resumed
// from the max updationTime seen on the first page (the tombstone's).
expect(sinceTimes).toEqual(["0", "150"]);
});
it("fetches only newer rows when the returned cursor is passed back in", async () => {
const keys = buildKeys();
const collectionKey = sodium.crypto_secretbox_keygen();
const { fetch, sinceTimes } = recordingFetch(
jsonResponse({ diff: [], hasMore: false }),
);
const client = Client.fromJSON(snapshotFor(keys), { fetch });
const result = await client.filesSince({
collectionID: 1,
collectionKey,
sinceTime: 200,
});
expect(result.files).toEqual([]);
expect(result.deleted).toEqual([]);
// An empty diff advances nothing: the cursor falls back to the input.
expect(result.cursor).toBe(200);
expect(sinceTimes).toEqual(["200"]);
});
it("stops and throws when the server claims more but does not advance (#7)", async () => {
const keys = buildKeys();
const collectionKey = sodium.crypto_secretbox_keygen();
// hasMore is true, but the page's max updationTime (50) does not exceed
// the cursor the request was made with (50). Following hasMore here
// would refetch this same page forever.
const { fetch, sinceTimes } = recordingFetch(
jsonResponse({ diff: [tombstoneRow(1, 50)], hasMore: true }),
jsonResponse({ diff: [tombstoneRow(1, 50)], hasMore: true }),
);
const client = Client.fromJSON(snapshotFor(keys), { fetch });
await expect(
client.filesSince({
collectionID: 1,
collectionKey,
sinceTime: 50,
}),
).rejects.toThrow(/not advance|non-advancing/i);
// It gave up after the first page rather than looping.
expect(sinceTimes).toEqual(["50"]);
});
});
describe("Client.collectionsSince", () => {
beforeAll(async () => {
await init();
await sodium.ready;
});
it("decrypts live collections, collects tombstones, and returns a cursor", async () => {
const keys = buildKeys();
const { fetch, sinceTimes } = recordingFetch(
jsonResponse({
collections: [
ownedCollectionRow(keys.masterKey, {
id: 1,
name: "Vacation",
updationTime: 100,
}),
tombstoneRow(3, 150),
],
}),
);
const client = Client.fromJSON(snapshotFor(keys), { fetch });
const { collections, deleted, cursor } = await client.collectionsSince({
sinceTime: 0,
});
expect(collections.map((c) => c.id)).toEqual([1]);
expect(collections[0]!.name).toBe("Vacation");
expect(deleted).toEqual([3]);
// The tombstone's updationTime advances the cursor too, so the next
// sync starts after it rather than seeing it again.
expect(cursor).toBe(150);
expect(sinceTimes).toEqual(["0"]);
});
it("falls back to the input cursor on an empty response", async () => {
const keys = buildKeys();
const { fetch, sinceTimes } = recordingFetch(
jsonResponse({ collections: [] }),
);
const client = Client.fromJSON(snapshotFor(keys), { fetch });
const result = await client.collectionsSince({ sinceTime: 150 });
expect(result.collections).toEqual([]);
expect(result.deleted).toEqual([]);
expect(result.cursor).toBe(150);
expect(sinceTimes).toEqual(["150"]);
});
});
describe("Client list wrappers still hide deletions", () => {
beforeAll(async () => {
await init();
await sodium.ready;
});
it("listFiles drops tombstones and returns only live files", async () => {
const keys = buildKeys();
const collectionKey = sodium.crypto_secretbox_keygen();
const { fetch, sinceTimes } = recordingFetch(
jsonResponse({
diff: [
fileRow(collectionKey, {
id: 7,
title: "keep.jpg",
updationTime: 100,
}),
tombstoneRow(8, 150),
],
hasMore: false,
}),
);
const client = Client.fromJSON(snapshotFor(keys), { fetch });
const files = await client.listFiles(1, collectionKey);
expect(files.map((f) => f.id)).toEqual([7]);
// The wrapper starts a full enumeration from zero.
expect(sinceTimes).toEqual(["0"]);
});
});
+197
View File
@@ -0,0 +1,197 @@
/**
* Tests for the client session lifecycle: `toJSON`, `fromJSON`, `logout`, and
* the CLI's `loadSession`, which reads the saved session file back into a
* client.
*/
import { mkdtempSync, 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 } 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";
const validSnapshot = (): ClientSnapshot => {
const kp = sodium.crypto_box_keypair();
return {
email: "user@example.com",
userID: 42,
token: "test-token",
masterKey: toBase64(sodium.crypto_secretbox_keygen()),
secretKey: toBase64(kp.privateKey),
publicKey: toBase64(kp.publicKey),
};
};
// The client's key buffers are private; the tests read them to prove that
// logout wipes them.
const keyBuffers = (client: Client): Uint8Array[] => [
client["masterKey"],
client["secretKey"],
client["publicKey"],
];
beforeAll(async () => {
await init();
});
describe("Client.toJSON", () => {
it("round-trips through fromJSON unchanged", () => {
const snapshot = validSnapshot();
expect(Client.fromJSON(snapshot).toJSON()).toEqual(snapshot);
});
it("throws instead of emitting a snapshot without a token", () => {
const client = Client.fromJSON(validSnapshot());
client.getApiClient().clearAuthToken();
expect(() => client.toJSON()).toThrow(/no auth token/);
});
});
describe("Client.fromJSON", () => {
const shortKey = toBase64(new Uint8Array(16));
it.each([
["email", undefined],
["email", 7],
["email", ""],
["token", undefined],
["token", null],
["token", ""],
["userID", undefined],
["userID", "42"],
["userID", 4.2],
["masterKey", undefined],
["masterKey", 7],
["masterKey", "not base64!"],
["masterKey", shortKey],
["secretKey", undefined],
["secretKey", "not base64!"],
["secretKey", shortKey],
["publicKey", undefined],
["publicKey", "not base64!"],
["publicKey", shortKey],
])("rejects %s = %j, naming the field", (field, value) => {
const snapshot: Record<string, unknown> = { ...validSnapshot() };
snapshot[field] = value;
expect(() => Client.fromJSON(snapshot)).toThrow(
new RegExp(`^Invalid session data: ${field} `),
);
});
it.each([null, "a string", 42])("rejects a non-object %j", (value) => {
expect(() => Client.fromJSON(value)).toThrow(
/^Invalid session data: not a JSON object/,
);
});
});
describe("Client.logout", () => {
it("zeroes the key buffers and clears the token", () => {
const client = Client.fromJSON(validSnapshot());
const api = client.getApiClient();
const keys = keyBuffers(client);
client.logout();
for (const key of keys) {
expect(key.length).toBe(32);
expect(key.every((b) => b === 0)).toBe(true);
}
expect(api.getAuthToken()).toBeUndefined();
});
it("makes every later operation throw", async () => {
const client = Client.fromJSON(validSnapshot());
client.logout();
expect(() => client.whoami()).toThrow(/logged out/);
expect(() => client.toJSON()).toThrow(/logged out/);
expect(() => client.getApiClient()).toThrow(/logged out/);
expect(() => client.contentSource()).toThrow(/logged out/);
await expect(client.listCollections()).rejects.toThrow(/logged out/);
await expect(client.collectionsSince({ sinceTime: 0 })).rejects.toThrow(
/logged out/,
);
await expect(
client.filesSince({
collectionID: 1,
collectionKey: new Uint8Array(32),
sinceTime: 0,
}),
).rejects.toThrow(/logged out/);
await expect(
client.fetchMLData({ fileIDs: [1], fileKeys: new Map() }),
).rejects.toThrow(/logged out/);
});
it("stops a listing in flight from decrypting with the zeroed keys", async () => {
// The server answers only after the client has logged out. If the
// listing went on to decrypt this row with all-zero keys it would fail
// with a decryption error, not the logged-out one.
const row = {
id: 1,
owner: { id: 42 },
encryptedKey: toBase64(new Uint8Array(48)),
keyDecryptionNonce: toBase64(new Uint8Array(24)),
updationTime: 1,
};
const client: Client = Client.fromJSON(validSnapshot(), {
fetch: async () => {
client.logout();
return new Response(JSON.stringify({ collections: [row] }), {
status: 200,
headers: { "content-type": "application/json" },
});
},
});
await expect(client.listCollections()).rejects.toThrow(/logged out/);
});
});
describe("loadSession", () => {
let dir: string;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), "quak-session-test-"));
});
afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});
it("returns null when there is no session file", () => {
expect(loadSession(join(dir, "missing.json"))).toBeNull();
});
it("restores a client from a valid session file", () => {
const path = join(dir, "valid.json");
writeFileSync(path, JSON.stringify(validSnapshot()));
expect(loadSession(path)!.whoami()).toEqual({
email: "user@example.com",
userID: 42,
});
});
it("says the file is corrupt when it is not JSON", () => {
const path = join(dir, "truncated.json");
writeFileSync(path, '{"email": "user@exa');
expect(() => loadSession(path)).toThrow(
`Session file ${path} is corrupt`,
);
});
it("says the file is corrupt and names the bad field", () => {
const path = join(dir, "bad-key.json");
writeFileSync(
path,
JSON.stringify({ ...validSnapshot(), secretKey: "AAAA" }),
);
expect(() => loadSession(path)).toThrow(
new RegExp(`^Session file ${path} is corrupt: .*secretKey`),
);
});
});
+568 -16
View File
@@ -48,7 +48,9 @@
*/
import {
chmodSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
@@ -72,7 +74,11 @@ 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 type { RetryOptions } from "../../src/retry.js";
import { downloadFile, downloadThumbnail } from "../../src/download/index.js";
import {
downloadFile,
downloadThumbnail,
writeAtomic,
} from "../../src/download/index.js";
import type { EnteFile, FileMetadata } from "../../src/model/types.js";
// ---------------------------------------------------------------------------
@@ -101,17 +107,79 @@ const renameHook = vi.hoisted(() => ({
failWith: null as Error | null,
}));
/**
* `open` is wrapped so the tests can observe the durability fsyncs the atomic
* writer performs — which are otherwise invisible: an fsync leaves no trace in
* the file's contents. Each `FileHandle.sync()` is recorded, and rename and
* sync events are appended to a single ordered `events` log so a test can pin
* the sequence "fsync the temp file, rename, fsync the directory" that makes a
* write survive a power cut. The flag the handle was opened with distinguishes
* the temp file (`w`) from its containing directory (`r`).
*/
const durabilityHook = vi.hoisted(() => ({
events: [] as string[],
}));
/**
* `FileHandle.write` is wrapped so the tests can watch the streaming decrypt
* path put plaintext on disk one chunk at a time. This is the direct evidence
* that memory is bounded by the chunk size and not the file size: a buffered
* downloader would hand the whole file to a single write, whereas the streaming
* one issues one write per secretstream chunk, none larger than
* `STREAM_CHUNK_SIZE`. Each write records the temp path it targeted and its
* length. `writeFile` (which the whole-buffer `writeAtomic` uses) is a distinct
* native call and does not go through this method, so only the streaming path
* is observed here.
*/
const writeHook = vi.hoisted(() => ({
writes: [] as { path: string; length: number }[],
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
const { existsSync: sourceExists } = await import("node:fs");
return {
...actual,
open: async (
path: Parameters<typeof actual.open>[0],
flags?: Parameters<typeof actual.open>[1],
...rest: unknown[]
): Promise<Awaited<ReturnType<typeof actual.open>>> => {
const handle = await actual.open(
path,
flags as Parameters<typeof actual.open>[1],
...(rest as []),
);
const realSync = handle.sync.bind(handle);
handle.sync = async (): Promise<void> => {
durabilityHook.events.push(`sync:${String(flags)}:${path}`);
await realSync();
};
const realWrite = handle.write.bind(handle);
handle.write = (async (
data: unknown,
...rest2: unknown[]
): Promise<unknown> => {
if (data instanceof Uint8Array) {
writeHook.writes.push({
path: String(path),
length: data.length,
});
}
return (realWrite as (...a: unknown[]) => Promise<unknown>)(
data,
...rest2,
);
}) as typeof handle.write;
return handle;
},
rename: async (from: string, to: string): Promise<void> => {
renameHook.calls.push({
from,
to,
sourceExisted: sourceExists(from),
});
durabilityHook.events.push(`rename:${to}`);
if (renameHook.failWith !== null) {
throw renameHook.failWith;
}
@@ -123,6 +191,8 @@ vi.mock("node:fs/promises", async (importOriginal) => {
beforeEach(() => {
renameHook.calls.length = 0;
renameHook.failWith = null;
durabilityHook.events.length = 0;
writeHook.writes.length = 0;
});
let testDir: string;
@@ -433,6 +503,22 @@ const entryPoints = [
{ name: "downloadThumbnail", download: downloadThumbnail },
];
// With no `outPath`, the destination is named after `metadata.title`, relative
// to the working directory. Such tests run inside a temporary directory:
// `make check` must not create files in the repo root.
const inDirectory = async <T>(
dir: string,
run: () => Promise<T>,
): Promise<T> => {
const previous = process.cwd();
process.chdir(dir);
try {
return await run();
} finally {
process.chdir(previous);
}
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -468,26 +554,59 @@ describe("downloadFile", () => {
});
it("uses metadata.title as filename when outPath is omitted", async () => {
// With no `outPath`, the destination is `metadata.title`, used
// verbatim as a path. The title here is therefore given inside the
// test's temporary directory: a bare relative name would resolve
// against the process working directory, i.e. the repo root, and
// `make check` must not create files in the repo — a failure between
// the write and any cleanup would leave one behind.
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);
const titlePath = join(testDir, "fallback-name.png");
file.metadata.title = titlePath;
file.metadata.title = "fallback-name.png";
const dir = mkdtempSync(join(testDir, "title-"));
const api = new ApiClient({ fetch: mockFetchForBody(ciphertext) });
const result = await downloadFile(api, file);
const result = await inDirectory(dir, () => downloadFile(api, file));
expect(result.path).toBe(titlePath);
expect(readFileSync(result.path)).toEqual(Buffer.from(plaintext));
expect(result.path).toBe("fallback-name.png");
expect(readFileSync(join(dir, "fallback-name.png"))).toEqual(
Buffer.from(plaintext),
);
});
it("keeps a hostile title inside the working directory", async () => {
// The server controls the title. `../escaped.png` must not write to
// the parent directory; it becomes one file name in the current one.
const { api, file } = fixtureFor(
multiChunkKey,
multiChunk.header,
multiChunk.body,
);
file.metadata.title = "../escaped.png";
const parent = mkdtempSync(join(testDir, "hostile-"));
const dir = join(parent, "cwd");
mkdirSync(dir);
const result = await inDirectory(dir, () => downloadFile(api, file));
expect(result.path).toBe("__escaped.png");
expect(readdirSync(dir)).toEqual(["__escaped.png"]);
expect(readdirSync(parent)).toEqual(["cwd"]);
});
it("uses an explicit outPath verbatim, even one with ..", async () => {
// The caller is trusted: its path is not sanitized.
const { api, file } = fixtureFor(
multiChunkKey,
multiChunk.header,
multiChunk.body,
);
const dir = mkdtempSync(join(testDir, "explicit-"));
mkdirSync(join(dir, "sub"));
const outPath = join(dir, "sub", "..", "explicit.bin");
const result = await downloadFile(api, file, outPath);
expect(result.path).toBe(outPath);
expect(existsSync(join(dir, "explicit.bin"))).toBe(true);
});
it("handles a larger single-chunk file (random binary payload)", async () => {
@@ -549,6 +668,23 @@ describe("downloadThumbnail", () => {
expect(result).toEqual({ path: outPath, bytesWritten: 4 });
expect(readFileSync(outPath)).toEqual(Buffer.from(plaintext));
});
it("names the thumbnail thumb_ plus the sanitized title", async () => {
const { api, file } = fixtureFor(
multiChunkKey,
multiChunk.header,
multiChunk.body,
);
file.metadata.title = "/etc/passwd";
const dir = mkdtempSync(join(testDir, "thumb-title-"));
const result = await inDirectory(dir, () =>
downloadThumbnail(api, file),
);
expect(result.path).toBe("thumb__etc_passwd");
expect(readdirSync(dir)).toEqual(["thumb__etc_passwd"]);
});
});
// ---------------------------------------------------------------------------
@@ -854,6 +990,51 @@ describe.each(entryPoints)(
expect(readFileSync(outPath)).toEqual(Buffer.from(existing));
expect(readdirSync(dir)).toEqual(["rename-fails.bin"]);
});
it("fails without creating anything when the destination directory does not exist", async () => {
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { header, ciphertext } = encryptFileBody(
patternBytes(64, 33),
key,
);
const { api, file } = fixtureFor(key, header, ciphertext);
const dir = freshDir();
const outPath = join(dir, "missing", "never.bin");
await expect(download(api, file, outPath)).rejects.toMatchObject({
code: "ENOENT",
});
// The missing directory is not created on the caller's behalf.
expect(readdirSync(dir)).toEqual([]);
});
// Root ignores directory permissions, so this cannot fail as root
// (the Docker test image runs as root).
it.skipIf(process.getuid?.() === 0)(
"fails without creating anything when the destination directory is not writable",
async () => {
const key =
sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { header, ciphertext } = encryptFileBody(
patternBytes(64, 34),
key,
);
const { api, file } = fixtureFor(key, header, ciphertext);
const dir = freshDir();
const outPath = join(dir, "never.bin");
chmodSync(dir, 0o500);
try {
await expect(
download(api, file, outPath),
).rejects.toMatchObject({ code: "EACCES" });
} finally {
chmodSync(dir, 0o700);
}
expect(readdirSync(dir)).toEqual([]);
},
);
},
);
@@ -911,10 +1092,13 @@ describe.each(entryPoints)("$name retries", ({ name, download }) => {
});
it("stages one temp file for the attempt that succeeded, not one per attempt", async () => {
// The atomic write stays outside the retry loop. A retried download
// must not leave a trail of half-written scratch files, and the
// destination must be touched exactly once — by the attempt that
// produced a complete, authenticated plaintext.
// Each streaming attempt stages into its own temp file, but a retried
// download must not leave a trail of half-written scratch files: a
// failed attempt removes its temp file, and the destination is renamed
// into place exactly once — by the attempt that produced a complete,
// authenticated plaintext. (Here the two failed attempts reset before a
// whole chunk is pulled, so they write nothing; the point stands either
// way — see the retry-restart test below, where they do write.)
const { key, header, ciphertext } = smallFixture(42);
const { fetch } = scriptedCdnFetch(
{ kind: "reset", bytes: ciphertext.slice(0, 16) },
@@ -1028,6 +1212,99 @@ describe.each(entryPoints)("$name retries", ({ name, download }) => {
});
});
// ---------------------------------------------------------------------------
// Streaming decrypt to disk
//
// The plaintext is never held whole in memory: each secretstream chunk is
// written to the temp file as it is decrypted, so peak memory is bounded by the
// chunk size rather than the file size. These tests watch the writes directly
// (see `writeHook`) rather than infer memory behaviour from the final file.
// ---------------------------------------------------------------------------
describe.each(entryPoints)("$name streams to disk", ({ name, download }) => {
const freshDir = (): string =>
mkdtempSync(join(testDir, `${name}-stream-`));
/** Writes recorded against staged temp files (not the `writeFile` path). */
const tempWrites = (): { path: string; length: number }[] =>
writeHook.writes.filter((w) => w.path.endsWith(".tmp"));
it("writes one chunk at a time, none larger than STREAM_CHUNK_SIZE", async () => {
// The multi-chunk fixture decrypts to one full 4 MiB chunk plus a small
// final chunk. A streaming writer therefore issues exactly two writes,
// of STREAM_CHUNK_SIZE and then the final chunk's length — never a
// single write carrying the whole 4 MiB + 1 KiB file. That per-chunk
// shape is what "memory bounded by chunk size" means in practice: the
// plaintext is handed to the filesystem and dropped, chunk by chunk.
const { api, file } = fixtureFor(
multiChunkKey,
multiChunk.header,
multiChunk.body,
);
const outPath = join(freshDir(), "streamed.bin");
const result = await download(api, file, outPath);
const writes = tempWrites();
expect(writes.map((w) => w.length)).toEqual([
STREAM_CHUNK_SIZE,
multiChunk.plaintext.length - STREAM_CHUNK_SIZE,
]);
// No single write ever carried the whole file, and every write fits in
// one chunk's worth of memory.
for (const w of writes) {
expect(w.length).toBeLessThanOrEqual(STREAM_CHUNK_SIZE);
}
expect(result.bytesWritten).toBe(multiChunk.plaintext.length);
expectSameBytes(readFileSync(outPath), multiChunk.plaintext);
});
it("restarts from byte zero on a retry, replacing the temp file cleanly", async () => {
// The secretstream pull state is not resumable, so a retry cannot
// continue a half-written file — it must start over. The first attempt
// here delivers a complete leading chunk and then stops before the
// TAG_FINAL chunk: 4 MiB of plaintext lands in a temp file, then the
// download is rejected as truncated and that temp file is discarded.
// The retry streams the whole body into a *fresh* temp file, so the
// destination ends up with exactly the plaintext once — never the
// leading chunk twice, and never a stale temp file left behind.
const truncatedBody = multiChunk.body.slice(
0,
multiChunk.finalChunkOffset,
);
const { fetch, requests } = scriptedCdnFetch(
{ kind: "body", bytes: truncatedBody },
{ kind: "body", bytes: multiChunk.body },
);
const api = new ApiClient({
fetch,
retry: { ...noWait, attempts: 4 },
});
const file = buildMockEnteFile(
multiChunkKey,
multiChunk.header,
multiChunk.header,
);
const dir = freshDir();
const outPath = join(dir, "retry-restart.bin");
const result = await download(api, file, outPath);
expect(requests()).toBe(2);
// Both attempts streamed to disk, each into its own temp file: the
// truncated first attempt wrote before it failed, proving the retry did
// not resume a partial file but replaced it.
const distinctTemps = new Set(tempWrites().map((w) => w.path));
expect(distinctTemps.size).toBe(2);
// The destination holds the complete plaintext exactly once, and no
// temp file survives.
expect(result.bytesWritten).toBe(multiChunk.plaintext.length);
expectSameBytes(readFileSync(outPath), multiChunk.plaintext);
expect(renameHook.calls).toHaveLength(1);
expect(readdirSync(dir)).toEqual(["retry-restart.bin"]);
});
});
describe("download retries: corruption is not retried", () => {
it("gives up immediately on a chunk that failed to authenticate", async () => {
// A whole chunk that failed to authenticate while the stream
@@ -1060,4 +1337,279 @@ describe("download retries: corruption is not retried", () => {
expect(requests()).toBe(1);
});
it("cancels the response body when decryption fails", async () => {
// A backup run carries on past a failed file, so a body left open on
// failure would hold its connection until garbage collection, once
// per failed file. This body delivers a corrupt chunk and then stays
// open, so only a cancel from the downloader can close it.
const corrupted = Uint8Array.from(multiChunk.body);
corrupted[10] ^= 0xff;
let cancelled = false;
const fetch = (async () =>
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(corrupted);
},
cancel() {
cancelled = true;
},
}),
{ status: 200 },
)) as typeof globalThis.fetch;
const api = new ApiClient({ fetch, retry: { ...noWait, attempts: 1 } });
const file = buildMockEnteFile(
multiChunkKey,
multiChunk.header,
multiChunk.header,
);
const outPath = join(mkdtempSync(join(testDir, "cancel-")), "c.bin");
await expect(downloadFile(api, file, outPath)).rejects.toThrow(
/authentication failed/i,
);
expect(cancelled).toBe(true);
});
it("cancels the response body when the temp file cannot be opened", async () => {
// The download fails before a byte of the body is read, so the body
// is still open and only a cancel from the downloader can close it.
let cancelled = false;
const fetch = (async () =>
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(multiChunk.body);
},
cancel() {
cancelled = true;
},
}),
{ status: 200 },
)) as typeof globalThis.fetch;
const api = new ApiClient({ fetch, retry: { ...noWait, attempts: 1 } });
const file = buildMockEnteFile(
multiChunkKey,
multiChunk.header,
multiChunk.header,
);
const outPath = join(
mkdtempSync(join(testDir, "cancel-")),
"missing",
"c.bin",
);
await expect(downloadFile(api, file, outPath)).rejects.toMatchObject({
code: "ENOENT",
});
expect(cancelled).toBe(true);
});
it("cancels the response body when the header is malformed", async () => {
// The header is rejected before the body is read, so the body is
// still open and only a cancel from the downloader can close it.
let cancelled = false;
const fetch = (async () =>
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(multiChunk.body);
},
cancel() {
cancelled = true;
},
}),
{ status: 200 },
)) as typeof globalThis.fetch;
const api = new ApiClient({ fetch, retry: { ...noWait, attempts: 1 } });
const shortHeader = multiChunk.header.subarray(0, 5);
const file = buildMockEnteFile(multiChunkKey, shortHeader, shortHeader);
const outPath = join(mkdtempSync(join(testDir, "cancel-")), "c.bin");
await expect(downloadFile(api, file, outPath)).rejects.toThrow();
expect(cancelled).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Fragmented network reads
//
// A CDN does not hand the body over one secretstream chunk at a time; it
// arrives in whatever pieces the socket produces, many of them far smaller than
// a chunk and most straddling a chunk boundary. `streamDecrypt` reassembles
// those pieces before decrypting, copying each received byte once rather than
// recopying the whole accumulator on every read. This is the path the other
// fixtures never take — their mock fetch delivers each body as a single
// `Response` value, i.e. one read — so it is exercised explicitly here.
// ---------------------------------------------------------------------------
/**
* A fetch that serves `body` through a `ReadableStream` sliced into many
* fixed-size pieces, imitating a socket that trickles bytes in. `pieceSize` is
* chosen not to divide the chunk framing evenly, so pieces straddle the
* `ENC_CHUNK_SIZE` boundary the downloader splits on — the case a single-value
* body can never produce. `emitted` reports how many pieces were yielded, so a
* test can assert the body really was fragmented and not delivered whole.
*/
const mockFetchForFragmentedBody = (
body: Uint8Array,
pieceSize: number,
): { fetch: typeof globalThis.fetch; emitted: () => number } => {
let pieces = 0;
const fake = async (): Promise<Response> =>
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
for (let off = 0; off < body.length; off += pieceSize) {
controller.enqueue(body.subarray(off, off + pieceSize));
pieces++;
}
controller.close();
},
}),
{ status: 200 },
);
return { fetch: fake as typeof globalThis.fetch, emitted: () => pieces };
};
describe("streamDecrypt fragmented reads", () => {
it("decrypts a multi-chunk body delivered in many small pieces", async () => {
// The multi-chunk fixture (one full 4 MiB chunk plus a small final
// chunk) delivered in 1000-byte pieces: several thousand reads, with
// the piece that spans the 4 MiB + 17 byte chunk boundary split across
// two chunks by the reassembler. The plaintext must come out
// byte-identical to the single-read case, and the chunk framing must be
// untouched: exactly two writes, `STREAM_CHUNK_SIZE` then the final
// chunk, the same as when the body arrives whole. If the boundary
// handling were off by a byte under fragmentation, either the pull
// would fail to authenticate or the write sizes would shift.
const { fetch, emitted } = mockFetchForFragmentedBody(
multiChunk.body,
1000,
);
const api = new ApiClient({ fetch });
const file = buildMockEnteFile(
multiChunkKey,
multiChunk.header,
multiChunk.header,
);
const dir = mkdtempSync(join(testDir, "fragmented-"));
const outPath = join(dir, "fragmented.bin");
const result = await downloadFile(api, file, outPath);
// The body really was trickled in, not handed over whole.
expect(emitted()).toBeGreaterThan(1000);
const writes = writeHook.writes.filter((w) => w.path.endsWith(".tmp"));
expect(writes.map((w) => w.length)).toEqual([
STREAM_CHUNK_SIZE,
multiChunk.plaintext.length - STREAM_CHUNK_SIZE,
]);
expect(result.bytesWritten).toBe(multiChunk.plaintext.length);
expectSameBytes(readFileSync(outPath), multiChunk.plaintext);
});
});
// ---------------------------------------------------------------------------
// Durable atomic writes
//
// `writeAtomic` is exported so the metadata store can reuse the same
// power-cut-safe write. Its durability is the point: the bytes and the new
// directory entry must both be on stable storage before it returns, so a crash
// immediately afterwards cannot resurrect an empty renamed file (#22 area 1).
// ---------------------------------------------------------------------------
describe("writeAtomic", () => {
it("fsyncs the temp file before the rename and the directory after", async () => {
const dir = mkdtempSync(join(testDir, "atomic-"));
const dest = join(dir, "durable.bin");
const bytes = patternBytes(2048, 71);
await writeAtomic(dest, bytes);
expect(readFileSync(dest)).toEqual(Buffer.from(bytes));
// The order is the durability contract: fsync the staged temp file so
// its contents are on disk, rename it into place, then fsync the
// directory so that new entry is on disk too. Do the directory fsync
// before the rename, or skip it, and a crash can lose the rename.
expect(durabilityHook.events).toHaveLength(3);
expect(durabilityHook.events[0]).toMatch(/^sync:w:.*\.tmp$/);
expect(durabilityHook.events[1]).toBe(`rename:${dest}`);
expect(durabilityHook.events[2]).toBe(`sync:r:${dir}`);
});
it("leaves no temp file behind when the write cannot be renamed", async () => {
const dir = mkdtempSync(join(testDir, "atomic-fail-"));
const dest = join(dir, "unrenamable.bin");
renameHook.failWith = new Error("simulated rename failure");
await expect(writeAtomic(dest, patternBytes(64, 72))).rejects.toThrow(
"simulated rename failure",
);
// The staged temp file was fsynced, then the rename failed; the cleanup
// path must remove it so a repeatedly failing write cannot fill the disk.
expect(existsSync(dest)).toBe(false);
expect(readdirSync(dir)).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// Per-chunk progress
//
// Callers streaming a large file want bytes-written as it lands, not only the
// final total. The hook fires as decrypted plaintext accumulates; its values
// are non-decreasing and its last value is exactly `bytesWritten`.
// ---------------------------------------------------------------------------
describe.each(entryPoints)("$name progress", ({ name, download }) => {
it("reports monotonic progress ending at bytesWritten", async () => {
// The multi-chunk fixture pulls one full 4 MiB chunk and then a small
// final chunk, so the callback fires more than once and monotonicity is
// actually observable rather than trivially true for a single fire.
const { api, file } = fixtureFor(
multiChunkKey,
multiChunk.header,
multiChunk.body,
);
const outPath = join(
mkdtempSync(join(testDir, `${name}-progress-`)),
"p.bin",
);
const seen: number[] = [];
const result = await download(api, file, outPath, (bytesDone) => {
seen.push(bytesDone);
});
expect(seen.length).toBeGreaterThan(1);
for (let i = 1; i < seen.length; i++) {
expect(seen[i]!).toBeGreaterThan(seen[i - 1]!);
}
expect(seen[seen.length - 1]).toBe(result.bytesWritten);
expect(result.bytesWritten).toBe(multiChunk.plaintext.length);
});
it("downloads normally when no progress callback is given", async () => {
// The callback is optional and its absence must be side-effect-free:
// the download succeeds exactly as it does elsewhere in this file.
const plaintext = patternBytes(300, 73);
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { header, ciphertext } = encryptFileBody(plaintext, key);
const { api, file } = fixtureFor(key, header, ciphertext);
const outPath = join(
mkdtempSync(join(testDir, `${name}-noprog-`)),
"n.bin",
);
const result = await download(api, file, outPath);
expect(result.bytesWritten).toBe(plaintext.length);
expectSameBytes(readFileSync(outPath), plaintext);
});
});
+93
View File
@@ -0,0 +1,93 @@
// File names built from server-supplied metadata.
//
// quak does not trust the server. A file's title and a collection's name are
// decrypted from data the server hands us, and a hostile server (or a
// compromised account) can set them to anything. quak uses them to name files
// on disk: `quak get` without `--out`, `downloadFile` without `outPath`, the
// backup's symlink and collection directories, and the extension of every file
// in the originals cache. Each of those goes through `sanitizeFileName` or
// `safeExtension`, so a title can only ever name one file inside the directory
// the caller chose.
//
// A path the user supplies (`--out`, `outPath`) is never sanitized: the caller
// is trusted, the server is not.
import { describe, expect, it } from "vitest";
import { safeExtension, sanitizeFileName } from "../../src/filename.js";
const FALLBACK = "file-42";
describe("sanitizeFileName", () => {
it("passes a normal title through unchanged", () => {
expect(sanitizeFileName("IMG_0001.HEIC", FALLBACK)).toBe(
"IMG_0001.HEIC",
);
expect(sanitizeFileName("Holiday 2024 (1).jpg", FALLBACK)).toBe(
"Holiday 2024 (1).jpg",
);
expect(sanitizeFileName("café.jpg", FALLBACK)).toBe("café.jpg");
});
it("cannot climb out of the directory with ../", () => {
// Without sanitizing, this would overwrite the user's SSH keys.
expect(sanitizeFileName("../../.ssh/authorized_keys", FALLBACK)).toBe(
"__.._.ssh_authorized_keys",
);
expect(sanitizeFileName("..", FALLBACK)).toBe("_");
expect(sanitizeFileName("..\\..\\x", FALLBACK)).toBe("__.._x");
});
it("cannot name an absolute path", () => {
expect(sanitizeFileName("/etc/passwd", FALLBACK)).toBe("_etc_passwd");
expect(sanitizeFileName("C:\\Windows\\x.dll", FALLBACK)).toBe(
"C__Windows_x.dll",
);
});
it("replaces embedded separators, so the name stays one file", () => {
expect(sanitizeFileName("a/b\\c.jpg", FALLBACK)).toBe("a_b_c.jpg");
});
it("replaces NUL and other control characters", () => {
// A NUL truncates the path in C code and makes Node's fs throw.
expect(sanitizeFileName("evil\0.jpg", FALLBACK)).toBe("evil_.jpg");
expect(sanitizeFileName("line\nbreak\x7f.jpg", FALLBACK)).toBe(
"line_break_.jpg",
);
});
it("does not produce a hidden file", () => {
expect(sanitizeFileName(".bashrc", FALLBACK)).toBe("_bashrc");
});
it("does not produce a Windows device name", () => {
expect(sanitizeFileName("CON", FALLBACK)).toBe("_CON");
expect(sanitizeFileName("nul.txt", FALLBACK)).toBe("_nul.txt");
expect(sanitizeFileName("LPT1", FALLBACK)).toBe("_LPT1");
// Only the exact names are reserved.
expect(sanitizeFileName("console.jpg", FALLBACK)).toBe("console.jpg");
});
it("falls back to the given name for an empty title", () => {
expect(sanitizeFileName("", FALLBACK)).toBe(FALLBACK);
});
});
describe("safeExtension", () => {
it("keeps a normal extension", () => {
expect(safeExtension("IMG_0001.HEIC")).toBe(".HEIC");
expect(safeExtension("clip.mp4")).toBe(".mp4");
});
it("uses .bin when there is no extension", () => {
expect(safeExtension("")).toBe(".bin");
expect(safeExtension("README")).toBe(".bin");
});
it("uses .bin when the extension holds anything but letters and digits", () => {
expect(safeExtension("x.j\\..\\pg")).toBe(".bin");
expect(safeExtension("x.jp g")).toBe(".bin");
expect(safeExtension("x.jpg\0")).toBe(".bin");
});
});
+358
View File
@@ -0,0 +1,358 @@
/**
* Tests for the originals cache size limit and LRU eviction (issue #47),
* layered on the on-disk content cache (#46).
*
* Only `cacheDirectory/originals` is bounded and evicted. Before each original
* write the effective limit is
* min(cacheOriginalsMaxBytes, bytesUsedByOriginals + bytesFree - freeBelowBytes)
* with `bytesFree` read from `statfs` on the volume holding `cacheDirectory`.
* The limit therefore falls as the disk fills and rises as space returns. When
* a write would cross the limit, least-recently-used originals are removed until
* it fits; pinned files are skipped, and if only pinned files remain the write
* proceeds over-limit. Last-use is the file `mtime`, bumped whenever a read
* returns an original's path, so ordering survives a restart with no ledger.
*
* `statfs` is injected so the adaptive limit is exercised deterministically:
* `bsize` is 1, so `bavail` is the free byte count the formula sees. The source
* writes a controllable number of bytes per file, and tests set each stored
* file's `mtime` explicitly so LRU order does not depend on wall-clock timing.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, existsSync, statSync, utimesSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
ContentCache,
type ContentSource,
type StatFsFn,
} from "../../src/library/content.js";
import { RequestPools } from "../../src/library/pools.js";
import type { EnteFile } from "../../src/model/types.js";
const file = (id: number): EnteFile => ({
id,
collectionID: 1,
ownerID: 1,
key: new Uint8Array([id & 0xff]),
metadata: {
title: `file-${id}.jpg`,
fileType: "image",
creationTime: 0,
modificationTime: 0,
},
file: { decryptionHeader: "aGVhZGVy" },
thumbnail: { decryptionHeader: "dGh1bWI=" },
updationTime: 0,
});
// A source that writes a controllable number of bytes per file (default 10).
class SizedSource implements ContentSource {
sizeFor = new Map<number, number>();
async original(args: {
file: EnteFile;
destination: string;
}): Promise<{ bytesWritten: number }> {
const n = this.sizeFor.get(args.file.id) ?? 10;
await writeFile(args.destination, Buffer.alloc(n, 1));
return { bytesWritten: n };
}
async thumbnail(args: {
file: EnteFile;
destination: string;
}): Promise<{ bytesWritten: number }> {
await writeFile(args.destination, Buffer.alloc(10, 1));
return { bytesWritten: 10 };
}
}
// A source whose original() writes 10 bytes but blocks on a gate before
// returning, so two fetches can be held in flight together. `bothStarted`
// resolves once both originals have entered, and `release()` lets them finish.
class GatedSource implements ContentSource {
private openGate!: () => void;
private readonly gate = new Promise<void>((r) => (this.openGate = r));
private inFlight = 0;
private reachedTwo!: () => void;
readonly bothStarted = new Promise<void>((r) => (this.reachedTwo = r));
async original(args: {
file: EnteFile;
destination: string;
}): Promise<{ bytesWritten: number }> {
this.inFlight += 1;
if (this.inFlight === 2) this.reachedTwo();
await this.gate;
await writeFile(args.destination, Buffer.alloc(10, 1));
return { bytesWritten: 10 };
}
async thumbnail(args: {
file: EnteFile;
destination: string;
}): Promise<{ bytesWritten: number }> {
await writeFile(args.destination, Buffer.alloc(10, 1));
return { bytesWritten: 10 };
}
release(): void {
this.openGate();
}
}
let root: string;
let cacheDir: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "quak-eviction-"));
cacheDir = join(root, "cache");
});
afterEach(() => {
if (root && existsSync(root))
rmSync(root, { recursive: true, force: true });
});
const originalPath = (id: number): string =>
join(cacheDir, "originals", `${id}.jpg`);
// Pin an original's mtime to a fixed second-resolution instant so LRU order is
// deterministic. Lower `seconds` = older = evicted first.
const setMtime = (id: number, seconds: number): void => {
utimesSync(originalPath(id), seconds, seconds);
};
const buildCache = (args: {
source?: ContentSource;
files?: EnteFile[];
statfs: StatFsFn;
cacheOriginalsMaxBytes?: number;
freeBelowBytes?: number;
isPinned?: (fileID: number) => boolean;
}): { cache: ContentCache; source: SizedSource } => {
const source = (args.source as SizedSource) ?? new SizedSource();
const byID = new Map<number, EnteFile>();
for (const f of args.files ?? [file(1), file(2), file(3), file(4), file(5)])
byID.set(f.id, f);
const cache = new ContentCache({
pools: new RequestPools(),
source,
cacheDirectory: cacheDir,
getFile: (id) => byID.get(id),
statfs: args.statfs,
cacheOriginalsMaxBytes: args.cacheOriginalsMaxBytes,
freeBelowBytes: args.freeBelowBytes,
isPinned: args.isPinned,
});
return { cache, source };
};
// Plenty of free space, so the configured max governs the limit unless a test
// dials it down. `bsize` of 1 makes `bavail` the free byte count.
const abundantFree: StatFsFn = async () => ({
bsize: 1,
bavail: 1_000_000_000,
});
describe("originals eviction", () => {
it("removes least-recently-used originals to fit the limit", async () => {
// Each original is 10 bytes; a 25-byte cap holds two.
const { cache } = buildCache({
statfs: abundantFree,
cacheOriginalsMaxBytes: 25,
freeBelowBytes: 0,
});
await cache.open();
await cache.original(1);
setMtime(1, 1000);
await cache.original(2);
setMtime(2, 2000);
// Writing the third (total 30 > 25) evicts the oldest, file 1.
await cache.original(3);
expect(existsSync(originalPath(1))).toBe(false);
expect(existsSync(originalPath(2))).toBe(true);
expect(existsSync(originalPath(3))).toBe(true);
expect(cache.pathsFor(1).originalPath).toBeUndefined();
expect(cache.originalsStatus().usedBytes).toBe(20);
});
it("skips pinned files, evicting the oldest unpinned instead", async () => {
const { cache } = buildCache({
statfs: abundantFree,
cacheOriginalsMaxBytes: 25,
freeBelowBytes: 0,
isPinned: (id) => id === 1,
});
await cache.open();
await cache.original(1);
setMtime(1, 1000); // oldest, but pinned
await cache.original(2);
setMtime(2, 2000);
await cache.original(3);
// File 1 is oldest but pinned, so file 2 is evicted instead.
expect(existsSync(originalPath(1))).toBe(true);
expect(existsSync(originalPath(2))).toBe(false);
expect(existsSync(originalPath(3))).toBe(true);
});
it("proceeds over-limit when only pinned files remain", async () => {
// A 5-byte cap cannot hold even one 10-byte original.
const { cache } = buildCache({
statfs: abundantFree,
cacheOriginalsMaxBytes: 5,
freeBelowBytes: 0,
isPinned: () => true,
});
await cache.open();
const result = await cache.original(1);
expect(existsSync(result.path)).toBe(true);
const status = cache.originalsStatus();
expect(status.usedBytes).toBe(10);
expect(status.usedBytes).toBeGreaterThan(status.limitBytes ?? 0);
});
it("keeps a just-written original larger than the limit, evicting it only on a later write", async () => {
// A 5-byte cap cannot hold even one 10-byte original, but a fetch must
// never evict the file it just wrote and is about to return.
const { cache } = buildCache({
statfs: abundantFree,
cacheOriginalsMaxBytes: 5,
freeBelowBytes: 0,
});
await cache.open();
const result = await cache.original(1);
// The just-written original survives its own over-budget write and the
// returned path exists on disk.
expect(existsSync(result.path)).toBe(true);
expect(existsSync(originalPath(1))).toBe(true);
expect(cache.originalsStatus().usedBytes).toBe(10);
setMtime(1, 1000);
// A later write finds file 1 eligible and evicts it to make room, while
// the newly written file 2 is itself kept over-limit.
await cache.original(2);
expect(existsSync(originalPath(1))).toBe(false);
expect(existsSync(originalPath(2))).toBe(true);
expect(cache.originalsStatus().usedBytes).toBe(10);
});
it("adapts the limit down as the disk fills and up as space returns", async () => {
// The configured max is generous; free space drives the limit. Eviction
// only kicks in once free space falls below the protected reserve.
let free = 1_000_000;
const statfs: StatFsFn = async () => ({ bsize: 1, bavail: free });
const { cache } = buildCache({
statfs,
cacheOriginalsMaxBytes: 1000,
freeBelowBytes: 100,
});
await cache.open();
// Ample free space: the limit is the configured max and nothing is
// evicted as three 10-byte originals accumulate.
await cache.original(1);
setMtime(1, 1000);
await cache.original(2);
setMtime(2, 2000);
await cache.original(3);
setMtime(3, 3000);
expect(cache.originalsStatus().limitBytes).toBe(1000);
expect(cache.originalsStatus().usedBytes).toBe(30);
// The disk fills: only 95 bytes free, below the 100-byte reserve. The
// next write (used 40) sees limit = min(1000, 40 + 95 - 100) = 35 and
// evicts the oldest original (file 1) to fit.
free = 95;
await cache.original(4);
expect(cache.originalsStatus().limitBytes).toBe(35);
expect(existsSync(originalPath(1))).toBe(false);
expect(cache.originalsStatus().usedBytes).toBe(30);
setMtime(4, 4000);
// Space returns: the limit rises back to the configured max and the
// next write is kept without eviction.
free = 1_000_000;
await cache.original(5);
expect(cache.originalsStatus().limitBytes).toBe(1000);
expect(existsSync(originalPath(4))).toBe(true);
expect(existsSync(originalPath(5))).toBe(true);
});
it("bumps an original's mtime when a read returns its path", async () => {
const { cache } = buildCache({
statfs: abundantFree,
cacheOriginalsMaxBytes: 1000,
freeBelowBytes: 0,
});
await cache.open();
await cache.original(1);
// Age the file well into the past.
setMtime(1, 1000);
expect(statSync(originalPath(1)).mtimeMs).toBeLessThan(2_000_000);
// A second read is a cache hit that must touch the file.
const before = Date.now();
await cache.original(1);
expect(statSync(originalPath(1)).mtimeMs).toBeGreaterThanOrEqual(
before - 2000,
);
});
it("keeps both originals when two over-budget fetches race", async () => {
// A 15-byte cap holds one 10-byte original but not two. Two fetches are
// held in flight together; when both stored files cross the limit,
// neither eviction pass may delete the sibling whose path has not yet
// been returned, so both survive over-limit.
const source = new GatedSource();
const { cache } = buildCache({
source,
statfs: abundantFree,
cacheOriginalsMaxBytes: 15,
freeBelowBytes: 0,
});
await cache.open();
const p1 = cache.original(1);
const p2 = cache.original(2);
await source.bothStarted; // both downloads are in flight before either stores
source.release();
const [r1, r2] = await Promise.all([p1, p2]);
// Both returned paths exist on disk even though together they exceed the
// cap; nothing was evicted out from under a fetch still in progress.
expect(existsSync(r1.path)).toBe(true);
expect(existsSync(r2.path)).toBe(true);
expect(existsSync(originalPath(1))).toBe(true);
expect(existsSync(originalPath(2))).toBe(true);
expect(cache.originalsStatus().usedBytes).toBe(20);
});
it("never counts or evicts thumbnails", async () => {
const { cache } = buildCache({
statfs: abundantFree,
cacheOriginalsMaxBytes: 5,
freeBelowBytes: 0,
});
await cache.open();
await cache.thumbnail(1);
await cache.thumbnail(2);
expect(cache.pathsFor(1).thumbnailPath).toBeDefined();
expect(cache.pathsFor(2).thumbnailPath).toBeDefined();
expect(cache.originalsStatus().usedBytes).toBe(0);
});
});
+160
View File
@@ -0,0 +1,160 @@
/**
* Integration between `Library` and the content cache (issue #46).
*
* The cache itself is covered in `content.test.ts`; this file locks the wiring:
* `Library.open` builds the cache from a content source, `lib.photos` hands out
* `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.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
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 { Collection, EnteFile } from "../../src/model/types.js";
const USER_ID = 7;
const collection = (id: number): Collection => ({
id,
ownerID: USER_ID,
key: new Uint8Array([id & 0xff]),
name: `album-${id}`,
type: "album",
updationTime: 1,
isShared: false,
});
const file = (id: number, collectionID: number): EnteFile => ({
id,
collectionID,
ownerID: USER_ID,
key: new Uint8Array([id & 0xff]),
metadata: {
title: `file-${id}.jpg`,
fileType: "image",
creationTime: 1,
modificationTime: 1,
},
file: { decryptionHeader: "aGVhZGVy" },
thumbnail: { decryptionHeader: "dGh1bWI=" },
updationTime: 1,
});
// A metadata-only client serving one album with one file, once.
class MockClient {
served = false;
whoami(): { email: string; userID: number } {
return { email: "u@example.com", userID: USER_ID };
}
async collectionsSince(): Promise<CollectionsPage> {
if (this.served) return { collections: [], deleted: [], cursor: 1 };
this.served = true;
return { collections: [collection(1)], deleted: [], cursor: 1 };
}
async filesSince(): Promise<FilesPage> {
return { files: [file(1, 1)], deleted: [], cursor: 1 };
}
}
// A content source that writes a marker file and counts thumbnail fetches.
const stubSource = (): ContentSource & { thumbCalls: () => number } => {
let thumbCalls = 0;
return {
thumbCalls: () => thumbCalls,
original: async ({ destination }) => {
writeFileSync(destination, "orig-bytes");
return { bytesWritten: 10 };
},
thumbnail: async ({ destination }) => {
thumbCalls++;
writeFileSync(destination, "thumb");
return { bytesWritten: 5 };
},
};
};
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "quak-content-lib-"));
});
afterEach(() => {
if (root && existsSync(root))
rmSync(root, { recursive: true, force: true });
});
describe("Library content wiring", () => {
it("fetches a thumbnail through a Photo and records its cache path", async () => {
const source = stubSource();
const lib = await Library.open({
client: new MockClient(),
cacheDirectory: join(root, "cache"),
contentSource: source,
refreshIntervalSeconds: 3600,
// On-demand wiring only; the background precache (#48) is covered
// in precache.test.ts and would race the exact-count assertions.
precacheThumbnails: false,
precacheOriginals: false,
});
const photo = lib.photos.byID({ fileID: 1 });
expect(photo).toBeDefined();
const result = await photo!.thumbnail();
expect(source.thumbCalls()).toBe(1);
expect(result.path).toBe(join(root, "cache", "thumbnails", "1.jpg"));
expect(existsSync(result.path)).toBe(true);
// The cached path is now on the projected record.
expect(lib.photos.byID({ fileID: 1 })!.record().thumbnailPath).toBe(
result.path,
);
await lib.close();
});
it("drives thumbnails.ensure through the cache", async () => {
const source = stubSource();
const lib = await Library.open({
client: new MockClient(),
cacheDirectory: join(root, "cache"),
contentSource: source,
refreshIntervalSeconds: 3600,
// On-demand wiring only; the background precache (#48) is covered
// in precache.test.ts and would race the exact-count assertions.
precacheThumbnails: false,
precacheOriginals: false,
});
const results = await lib.thumbnails.ensure({
fileIDs: [1],
priority: "visible",
});
expect(results).toEqual([
{ fileID: 1, path: join(root, "cache", "thumbnails", "1.jpg") },
]);
await lib.close();
});
it("throws from content methods when opened without a content source", async () => {
const lib = await Library.open({
client: new MockClient(),
cacheDirectory: join(root, "cache"),
refreshIntervalSeconds: 3600,
});
await expect(
lib.photos.byID({ fileID: 1 })!.thumbnail(),
).rejects.toThrow(/content cache/i);
await expect(
lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }),
).rejects.toThrow(/content cache/i);
await lib.close();
});
});
+442
View File
@@ -0,0 +1,442 @@
/**
* Tests for the on-disk content and thumbnail cache (issue #46).
*
* The cache keys stored bytes by `fileID` under `cacheDirectory`:
* `originals/<fileID>.<ext>` and `thumbnails/<fileID>.<ext>`. Its contract:
*
* 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.
* 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
* files a crashed write may have left are reaped there.
* 3. **`thumbnails.ensure` drives the thumbnail pool with priority, dedup, and
* abort.** A `fileID` asked for twice downloads once; a visible request is
* served ahead of a background one; and an `AbortSignal` drops work still
* queued while letting an in-flight fetch finish.
*
* The `ContentSource` is a stand-in: it writes deterministic bytes to the
* destination and returns the count, so the cache logic is exercised with no
* crypto and no network. Ordering tests gate the stand-in on explicit deferreds
* and assert the persisted result, never a bare call or a timer.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
mkdtempSync,
rmSync,
existsSync,
writeFileSync,
mkdirSync,
statSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
ContentCache,
type ContentSource,
type EnsureEvent,
} from "../../src/library/content.js";
import { RequestPools } from "../../src/library/pools.js";
import type { EnteFile } from "../../src/model/types.js";
const file = (id: number, title = `file-${id}.jpg`): EnteFile => ({
id,
collectionID: 1,
ownerID: 1,
key: new Uint8Array([id & 0xff]),
metadata: {
title,
fileType: "image",
creationTime: 0,
modificationTime: 0,
},
file: { decryptionHeader: "aGVhZGVy" },
thumbnail: { decryptionHeader: "dGh1bWI=" },
updationTime: 0,
});
// A deferred with externally callable resolve, used to gate the stand-in source
// so ordering is controlled by the test rather than by timing.
const deferred = (): { promise: Promise<void>; resolve: () => void } => {
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
return { promise, resolve };
};
// A ContentSource that writes `${kind}:${fileID}` bytes to the destination and
// records every call. `gate` optionally blocks a call until released, and
// `completed` records the order in which fetches finished — the observable used
// by the priority and abort tests instead of a timer.
class StubSource implements ContentSource {
originalCalls: number[] = [];
thumbnailCalls: number[] = [];
completed: number[] = [];
emptyFor = new Set<number>();
gates = new Map<number, Promise<void>>();
private async run(
kind: "original" | "thumbnail",
file: EnteFile,
destination: string,
): Promise<{ bytesWritten: number }> {
const gate = this.gates.get(file.id);
if (gate) await gate;
const bytes = this.emptyFor.has(file.id)
? new Uint8Array(0)
: new TextEncoder().encode(`${kind}:${file.id}`);
writeFileSync(destination, bytes);
this.completed.push(file.id);
return { bytesWritten: bytes.length };
}
async original(args: {
file: EnteFile;
destination: string;
}): Promise<{ bytesWritten: number }> {
this.originalCalls.push(args.file.id);
return this.run("original", args.file, args.destination);
}
async thumbnail(args: {
file: EnteFile;
destination: string;
}): Promise<{ bytesWritten: number }> {
this.thumbnailCalls.push(args.file.id);
return this.run("thumbnail", args.file, args.destination);
}
}
let root: string;
let cacheDir: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "quak-content-"));
cacheDir = join(root, "cache");
});
afterEach(() => {
if (root && existsSync(root))
rmSync(root, { recursive: true, force: true });
});
const buildCache = (
args: {
source?: ContentSource;
files?: EnteFile[];
pools?: RequestPools;
downloadDirectory?: string;
} = {},
): { cache: ContentCache; source: StubSource } => {
const source = (args.source as StubSource) ?? new StubSource();
const byID = new Map<number, EnteFile>();
for (const f of args.files ?? [file(1), file(2), file(3)])
byID.set(f.id, f);
const cache = new ContentCache({
pools: args.pools ?? new RequestPools(),
source,
cacheDirectory: cacheDir,
downloadDirectory: args.downloadDirectory,
getFile: (id) => byID.get(id),
});
return { cache, source };
};
describe("ContentCache.open", () => {
it("creates the cache directories with 0700 permissions", async () => {
const { cache } = buildCache();
await cache.open();
const originals = join(cacheDir, "originals");
const thumbnails = join(cacheDir, "thumbnails");
expect(existsSync(originals)).toBe(true);
expect(existsSync(thumbnails)).toBe(true);
expect(statSync(originals).mode & 0o777).toBe(0o700);
expect(statSync(thumbnails).mode & 0o777).toBe(0o700);
});
it("reaps orphan temp files but keeps complete content", async () => {
const originals = join(cacheDir, "originals");
const thumbnails = join(cacheDir, "thumbnails");
mkdirSync(originals, { recursive: true });
mkdirSync(thumbnails, { recursive: true });
const orphan = join(originals, ".quak-abc123.tmp");
const complete = join(originals, "1.jpg");
const thumb = join(thumbnails, "2.jpg");
writeFileSync(orphan, "half-written");
writeFileSync(complete, "whole");
writeFileSync(thumb, "whole-thumb");
const { cache } = buildCache();
await cache.open();
expect(existsSync(orphan)).toBe(false);
expect(existsSync(complete)).toBe(true);
expect(existsSync(thumb)).toBe(true);
});
it("records already-cached files so their paths appear in pathsFor", async () => {
const originals = join(cacheDir, "originals");
const thumbnails = join(cacheDir, "thumbnails");
mkdirSync(originals, { recursive: true });
mkdirSync(thumbnails, { recursive: true });
writeFileSync(join(originals, "1.jpg"), "orig");
writeFileSync(join(thumbnails, "1.jpg"), "thumb");
const { cache } = buildCache();
await cache.open();
expect(cache.pathsFor(1)).toEqual({
originalPath: join(originals, "1.jpg"),
thumbnailPath: join(thumbnails, "1.jpg"),
});
expect(cache.pathsFor(2)).toEqual({});
});
});
describe("ContentCache.original / thumbnail", () => {
it("fetches once, then serves the cached file with a single skipped event", async () => {
const { cache, source } = buildCache();
await cache.open();
const events: string[] = [];
const first = await cache.original(1, {
onProgress: (e) => events.push(e.status),
});
expect(source.originalCalls).toEqual([1]);
expect(first.path).toBe(join(cacheDir, "originals", "1.jpg"));
expect(first.bytes).toBe("original:1".length);
expect(existsSync(first.path)).toBe(true);
expect(statSync(first.path).mode & 0o777).toBe(0o600);
expect(cache.pathsFor(1).originalPath).toBe(first.path);
const skips: string[] = [];
const second = await cache.original(1, {
onProgress: (e) => skips.push(e.status),
});
// No second download, and exactly one skipped event.
expect(source.originalCalls).toEqual([1]);
expect(second.path).toBe(first.path);
expect(skips).toEqual(["skipped"]);
});
it("takes only a letters-and-digits extension from the title", async () => {
// The title comes from the server; an extension such as `.\..\x`
// must not reach the cache file name, so it becomes `.bin`.
const { cache } = buildCache({
files: [file(1, "a.jpg"), file(2, "b.\\..\\x"), file(3, "")],
});
await cache.open();
expect((await cache.original(1)).path).toBe(
join(cacheDir, "originals", "1.jpg"),
);
expect((await cache.original(2)).path).toBe(
join(cacheDir, "originals", "2.bin"),
);
expect((await cache.original(3)).path).toBe(
join(cacheDir, "originals", "3.bin"),
);
});
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");
writeFileSync(backupPath, "from-backup");
const { cache, source } = buildCache({ downloadDirectory });
await cache.open();
const events: EnsureEvent["status"][] = [];
const result = await cache.original(1, {
onProgress: (e) => events.push(e.status),
});
expect(source.originalCalls).toEqual([]);
expect(result.path).toBe(backupPath);
expect(result.bytes).toBe("from-backup".length);
expect(events).toEqual(["skipped"]);
});
it("fetches and caches a thumbnail", async () => {
const { cache, source } = buildCache();
await cache.open();
const result = await cache.thumbnail(2);
expect(source.thumbnailCalls).toEqual([2]);
expect(result.path).toBe(join(cacheDir, "thumbnails", "2.jpg"));
expect(existsSync(result.path)).toBe(true);
expect(cache.pathsFor(2).thumbnailPath).toBe(result.path);
});
it("shares one download between concurrent callers for the same file", async () => {
const { cache, source } = buildCache();
await cache.open();
const gate = deferred();
source.gates.set(1, gate.promise);
const a = cache.original(1);
const b = cache.original(1);
gate.resolve();
const [ra, rb] = await Promise.all([a, b]);
expect(source.originalCalls).toEqual([1]);
expect(ra.path).toBe(rb.path);
});
it("does not record a path when the fetched file is empty", async () => {
const { cache, source } = buildCache();
source.emptyFor.add(1);
await cache.open();
await expect(cache.original(1)).rejects.toThrow(/empty/i);
expect(cache.pathsFor(1).originalPath).toBeUndefined();
});
it("rejects an unknown file", async () => {
const { cache } = buildCache({ files: [] });
await cache.open();
await expect(cache.original(999)).rejects.toThrow(/unknown file/i);
});
});
describe("ContentCache.ensureThumbnails", () => {
it("downloads once for a file listed twice and reports every id", async () => {
const { cache, source } = buildCache();
await cache.open();
const results = await cache.ensureThumbnails({
fileIDs: [1, 1, 2],
priority: "visible",
});
expect(source.thumbnailCalls.sort()).toEqual([1, 2]);
expect(results).toEqual([
{ fileID: 1, path: join(cacheDir, "thumbnails", "1.jpg") },
{ fileID: 2, path: join(cacheDir, "thumbnails", "2.jpg") },
]);
});
it("skips present files and reports a skipped event", async () => {
const thumbnails = join(cacheDir, "thumbnails");
mkdirSync(thumbnails, { recursive: true });
writeFileSync(join(thumbnails, "1.jpg"), "present");
const { cache, source } = buildCache();
await cache.open();
const events: EnsureEvent[] = [];
const results = await cache.ensureThumbnails({
fileIDs: [1, 2],
priority: "ahead",
onProgress: (e) => events.push(e),
});
expect(source.thumbnailCalls).toEqual([2]);
expect(results).toEqual([
{ fileID: 1, path: join(thumbnails, "1.jpg") },
{ fileID: 2, path: join(thumbnails, "2.jpg") },
]);
expect(events).toContainEqual({
fileID: 1,
status: "skipped",
path: join(thumbnails, "1.jpg"),
});
});
it("serves a visible request ahead of an already-queued background one", async () => {
// One thumbnail slot, so exactly one fetch runs at a time and the rest
// wait in the pool. A background fetch takes the slot; a background and
// a visible fetch queue behind it. When the slot frees, the pool must
// pick the visible (on-demand) request ahead of the background one that
// was submitted first. The completion order is the observable.
const pools = new RequestPools({ thumbnailConcurrency: 1 });
const { cache, source } = buildCache({ pools });
await cache.open();
const gateA = deferred();
const gateB = deferred();
const gateC = deferred();
source.gates.set(1, gateA.promise);
source.gates.set(2, gateB.promise);
source.gates.set(3, gateC.promise);
const bgFirst = cache.ensureThumbnails({
fileIDs: [1],
priority: "background",
});
// Let fetch 1 take the only slot before the others queue.
await Promise.resolve();
const bgSecond = cache.ensureThumbnails({
fileIDs: [2],
priority: "background",
});
const visible = cache.ensureThumbnails({
fileIDs: [3],
priority: "visible",
});
gateA.resolve();
gateC.resolve();
gateB.resolve();
await Promise.all([bgFirst, bgSecond, visible]);
// 1 ran first (it held the slot). Of the two that were queued, the
// visible id 3 was served before the background id 2.
expect(source.completed).toEqual([1, 3, 2]);
});
it("drops queued work on abort but keeps an in-flight fetch", async () => {
const pools = new RequestPools({ thumbnailConcurrency: 1 });
const { cache, source } = buildCache({ pools });
await cache.open();
const gate = deferred();
source.gates.set(1, gate.promise);
const controller = new AbortController();
const pending = cache.ensureThumbnails({
fileIDs: [1, 2],
priority: "ahead",
signal: controller.signal,
});
// Fetch 1 is in flight (holds the slot); 2 is queued.
await Promise.resolve();
controller.abort();
gate.resolve();
const results = await pending;
// The in-flight fetch finished and is kept; the queued one was dropped
// before it ran.
expect(source.thumbnailCalls).toEqual([1]);
expect(results).toEqual([
{ fileID: 1, path: join(cacheDir, "thumbnails", "1.jpg") },
{ fileID: 2, error: "aborted" },
]);
});
it("captures a per-file failure without failing the batch", async () => {
const { cache } = buildCache({ files: [file(1)] });
await cache.open();
const results = await cache.ensureThumbnails({
fileIDs: [1, 2],
priority: "background",
});
expect(results[0]).toEqual({
fileID: 1,
path: join(cacheDir, "thumbnails", "1.jpg"),
});
expect(results[1]?.fileID).toBe(2);
expect(results[1]?.error).toMatch(/unknown file/i);
});
});
+272
View File
@@ -0,0 +1,272 @@
/**
* Tests for fresh reads (issue #75, an owner amendment to design #36).
*
* The default read namespaces answer from RAM and never touch the network; a
* background loop keeps the local copy current. `fresh()` adds an awaited path:
* it forces a refresh, waits for it to complete and persist, and only then
* hands back the `albums`/`photos`/`timeline` namespaces, guaranteeing the
* local copy reflects a completed server round-trip. The contracts here:
*
* 1. A fresh read observes a server change that a same-instant default read
* would miss (the default path has not refreshed yet).
* 2. Concurrent fresh reads coalesce onto one in-flight refresh three
* concurrent `fresh()` calls make exactly one collections round-trip, not
* 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.
*
* 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
* refresh interval keeps the background timer out of the way so each test's
* refreshes are exactly the ones it triggers.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Library } from "../../src/library/index.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
const USER_ID = 42;
// Long enough that no background tick fires during a test; each test's
// refreshes are only the ones its own `fresh()` calls force.
const SLOW_INTERVAL = 3600;
const collection = (id: number, updationTime: number): Collection => ({
id,
ownerID: USER_ID,
key: new Uint8Array([id & 0xff]),
name: `album-${id}`,
type: "album",
updationTime,
isShared: false,
});
const file = (
id: number,
collectionID: number,
updationTime: number,
): EnteFile => ({
id,
collectionID,
ownerID: USER_ID,
key: new Uint8Array([id & 0xff]),
metadata: {
title: `file-${id}.jpg`,
fileType: "image",
creationTime: updationTime,
modificationTime: updationTime,
},
file: { decryptionHeader: "aGVhZGVy" },
thumbnail: { decryptionHeader: "dGh1bWI=" },
updationTime,
});
class MockClient {
userID = USER_ID;
failCollections = false;
collectionsQueue: CollectionsPage[] = [];
filesByCollection = new Map<number, FilesPage[]>();
collectionsSinceTimes: number[] = [];
filesCalls: { collectionID: number; sinceTime: number }[] = [];
whoami(): { email: string; userID: number } {
return { email: "user@example.com", userID: this.userID };
}
async collectionsSince(args: {
sinceTime: number;
}): Promise<CollectionsPage> {
this.collectionsSinceTimes.push(args.sinceTime);
if (this.failCollections) throw new Error("network down");
return (
this.collectionsQueue.shift() ?? {
collections: [],
deleted: [],
cursor: args.sinceTime,
}
);
}
async filesSince(args: {
collectionID: number;
collectionKey: Uint8Array;
sinceTime: number;
}): Promise<FilesPage> {
this.filesCalls.push({
collectionID: args.collectionID,
sinceTime: args.sinceTime,
});
const queue = this.filesByCollection.get(args.collectionID);
return (
queue?.shift() ?? {
files: [],
deleted: [],
cursor: args.sinceTime,
}
);
}
filesFor(collectionID: number, ...pages: FilesPage[]): void {
this.filesByCollection.set(collectionID, pages);
}
}
// A client seeded with one collection and one file, opened with an empty cache
// so the initial refresh is awaited and post-`open()` state is deterministic.
const openSeeded = async (
cacheDirectory: string,
): Promise<{ client: MockClient; lib: Library }> => {
const client = new MockClient();
client.collectionsQueue.push({
collections: [collection(1, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(1, {
files: [file(1001, 1, 90)],
deleted: [],
cursor: 90,
});
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: SLOW_INTERVAL,
});
return { client, lib };
};
describe("Library.fresh", () => {
let dir: string;
let cacheDirectory: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "quak-fresh-"));
cacheDirectory = join(dir, "cache");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("observes a server change a same-instant default read would miss", async () => {
const { client, lib } = await openSeeded(cacheDirectory);
try {
// A new file appears on the server after open, advancing its
// collection so the next refresh re-enumerates it.
client.collectionsQueue.push({
collections: [collection(1, 200)],
deleted: [],
cursor: 200,
});
client.filesFor(1, {
files: [file(1002, 1, 190)],
deleted: [],
cursor: 190,
});
// A default read at this instant has not refreshed: it misses 1002.
expect(lib.photos.byID({ fileID: 1002 })).toBeUndefined();
// A fresh read forces the round-trip and sees it.
const reads = await lib.fresh();
expect(reads.photos.byID({ fileID: 1002 })?.fileID).toBe(1002);
// And the change is now live for the default namespaces too.
expect(lib.photos.byID({ fileID: 1002 })?.fileID).toBe(1002);
} finally {
await lib.close();
}
});
it("coalesces concurrent fresh reads onto one in-flight refresh", async () => {
const { client, lib } = await openSeeded(cacheDirectory);
try {
client.collectionsQueue.push({
collections: [collection(1, 200)],
deleted: [],
cursor: 200,
});
client.filesFor(1, {
files: [file(1002, 1, 190)],
deleted: [],
cursor: 190,
});
const collectionsBefore = client.collectionsSinceTimes.length;
const filesBefore = client.filesCalls.length;
// Gate the next collections fetch so all three fresh reads are in
// flight together before any of them completes.
let release: () => void = () => {};
const gate = new Promise<void>((r) => {
release = r;
});
const inner = client.collectionsSince.bind(client);
client.collectionsSince = async (args: { sinceTime: number }) => {
await gate;
return inner(args);
};
const all = Promise.all([lib.fresh(), lib.fresh(), lib.fresh()]);
release();
const [a, b, c] = await all;
// Exactly one collections round-trip and one file round-trip served
// all three fresh reads.
expect(client.collectionsSinceTimes.length).toBe(
collectionsBefore + 1,
);
expect(client.filesCalls.length).toBe(filesBefore + 1);
// All three observed the change.
for (const reads of [a, b, c]) {
expect(reads.photos.byID({ fileID: 1002 })?.fileID).toBe(1002);
}
} finally {
await lib.close();
}
});
it("rejects the fresh read when the refresh fails, leaving defaults intact", async () => {
const { client, lib } = await openSeeded(cacheDirectory);
try {
client.failCollections = true;
// Two concurrent fresh reads both reject, and share one failed
// round-trip rather than each making its own.
const collectionsBefore = client.collectionsSinceTimes.length;
const first = lib.fresh();
const second = lib.fresh();
await expect(first).rejects.toThrow(/network down/);
await expect(second).rejects.toThrow(/network down/);
expect(client.collectionsSinceTimes.length).toBe(
collectionsBefore + 1,
);
// The default reads never rejected: they still serve the last good
// copy, and the failure surfaced through status().
expect(lib.photos.byID({ fileID: 1001 })?.fileID).toBe(1001);
expect(lib.status().lastError).toMatch(/network down/);
// Recovery: once the server answers, a fresh read resolves current.
client.failCollections = false;
client.collectionsQueue.push({
collections: [collection(2, 300)],
deleted: [],
cursor: 300,
});
const reads = await lib.fresh();
expect(reads.albums.byID({ collectionID: 2 })?.collectionID).toBe(
2,
);
expect(lib.status().lastError).toBeUndefined();
} finally {
await lib.close();
}
});
});
+741
View File
@@ -0,0 +1,741 @@
/**
* Tests for `Library.open()` and its transparent background refresh loop.
*
* The library keeps the account's server state in a `MetadataStore` (issue
* #41) and pulls changes with the resumable, tombstone-aware enumerators on
* `Client` (issue #38: `collectionsSince` / `filesSince`). `open()` loads the
* cache, does one refresh, then refreshes again every `refreshIntervalSeconds`
* on a background timer. The design (#36) forbids an exposed `sync()`, a
* `serverReachable` flag, a `lib.refresh()` method, and a "before each read"
* mode. The contracts exercised here:
*
* 1. Reads are answered from RAM. A read never calls the client.
* 2. `open()` does an initial refresh, then the interval keeps refreshing;
* each refresh resumes from the stored cursor and applies diffs + tombstones.
* 3. The cache is rewritten only when a refresh actually changes something.
* 4. A failed refresh is invisible to reads: the last good data stays, the
* failure surfaces via `onProgress` ("failed") and `status()`, and a later
* success clears the error. `open()` itself resolves even when the first
* refresh fails (offline start from cache).
* 5. `close()` stops the timer and is idempotent, and its promise resolves
* only once an in-flight refresh has written the cache file.
* 6. `cacheDirectory` defaults to the env-paths cache dir plus the user id.
* 7. `open()` branches on the cache: an empty cache awaits the first refresh
* (it has nothing to serve yet); an existing cache serves its copy at once
* and refreshes in the background, so a slow or dead server never stalls
* 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.
*
* 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.
*
* On an empty cache `open()` awaits the initial refresh (including its cache
* write), so state right after `open()` is deterministic; the tests that
* inspect post-`open()` state seed no cache and rely on that. Tests for an
* existing-cache open seed a store first and prove `open()` returns without
* waiting for the network. The interval tests then use real timers with a
* short interval and `vi.waitFor`: a fake clock cannot settle the real
* fsync-and-rename cache write, and empty diffs never write, so the eventual
* state is stable to poll for.
*
* A refresh changes RAM before it writes the cache file, so a polled state can
* be visible while that write is still running. Every test therefore awaits
* `close()`, which waits for the in-flight refresh, before `afterEach` removes
* the directory.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import envPaths from "env-paths";
import { Library, type RefreshEvent } from "../../src/library/index.js";
import { MetadataStore } from "../../src/library/store.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
const USER_ID = 42;
// Short enough that a couple of ticks pass within a test, long enough not to
// spin; interval tests poll for the eventual state rather than counting ticks.
const FAST_INTERVAL = 0.02;
const collection = (
id: number,
updationTime: number,
name = `album-${id}`,
): Collection => ({
id,
ownerID: USER_ID,
key: new Uint8Array([id & 0xff]),
name,
type: "album",
updationTime,
isShared: false,
});
const file = (
id: number,
collectionID: number,
updationTime: number,
): EnteFile => ({
id,
collectionID,
ownerID: USER_ID,
key: new Uint8Array([id & 0xff]),
metadata: {
title: `file-${id}.jpg`,
fileType: "image",
creationTime: updationTime,
modificationTime: updationTime,
},
file: { decryptionHeader: "aGVhZGVy" },
thumbnail: { decryptionHeader: "dGh1bWI=" },
updationTime,
});
/**
* A mock `Client`. `collectionsSince` shifts one page off `collectionsQueue`
* per call (an empty diff that advances nothing when the queue runs dry);
* `filesSince` shifts from a per-collection queue. `failCollections` makes the
* next and all further collection fetches throw, to simulate an offline server.
*/
class MockClient {
userID = USER_ID;
failCollections = false;
collectionsQueue: CollectionsPage[] = [];
filesByCollection = new Map<number, FilesPage[]>();
collectionsSinceTimes: number[] = [];
filesCalls: { collectionID: number; sinceTime: number }[] = [];
whoami(): { email: string; userID: number } {
return { email: "user@example.com", userID: this.userID };
}
async collectionsSince(args: {
sinceTime: number;
}): Promise<CollectionsPage> {
this.collectionsSinceTimes.push(args.sinceTime);
if (this.failCollections) throw new Error("network down");
return (
this.collectionsQueue.shift() ?? {
collections: [],
deleted: [],
cursor: args.sinceTime,
}
);
}
async filesSince(args: {
collectionID: number;
collectionKey: Uint8Array;
sinceTime: number;
}): Promise<FilesPage> {
this.filesCalls.push({
collectionID: args.collectionID,
sinceTime: args.sinceTime,
});
const queue = this.filesByCollection.get(args.collectionID);
return (
queue?.shift() ?? {
files: [],
deleted: [],
cursor: args.sinceTime,
}
);
}
filesFor(collectionID: number, ...pages: FilesPage[]): void {
this.filesByCollection.set(collectionID, pages);
}
}
describe("Library.open and background refresh", () => {
let dir: string;
let cacheDirectory: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "quak-library-"));
cacheDirectory = join(dir, "cache");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("does an initial refresh and answers reads from the cache", async () => {
const client = new MockClient();
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,
});
const lib = await Library.open({ client, cacheDirectory });
try {
expect(lib.listCollections().map((c) => c.id)).toEqual([1]);
expect(lib.listFiles(1).map((f) => f.id)).toEqual([1001, 1002]);
expect(lib.getFile(1, 1001)?.metadata.title).toBe("file-1001.jpg");
const status = lib.status();
expect(status.userID).toBe(USER_ID);
expect(status.collections).toBe(1);
expect(status.files).toBe(2);
expect(status.lastRefreshAt).toBeGreaterThan(0);
expect(status.lastError).toBeUndefined();
// The initial refresh persisted the cache to disk.
const reloaded = await MetadataStore.load(
join(cacheDirectory, "metadata.json"),
);
expect(reloaded.getFile(1, 1001)?.id).toBe(1001);
expect(reloaded.collectionsSinceTime).toBe(100);
} finally {
await lib.close();
}
});
it("reads never call the client", async () => {
const client = new MockClient();
client.collectionsQueue.push({
collections: [collection(1, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(1, {
files: [file(1001, 1, 90)],
deleted: [],
cursor: 90,
});
const lib = await Library.open({ client, cacheDirectory });
try {
const collectionCalls = client.collectionsSinceTimes.length;
const fileCalls = client.filesCalls.length;
lib.listCollections();
lib.getCollection(1);
lib.listFiles(1);
lib.getFile(1, 1001);
lib.status();
expect(client.collectionsSinceTimes.length).toBe(collectionCalls);
expect(client.filesCalls.length).toBe(fileCalls);
} finally {
await lib.close();
}
});
it("resumes each refresh from the stored cursor", async () => {
// Seed a cache with a cursor and a collection, as a prior run left it.
const path = join(cacheDirectory, "metadata.json");
const seed = await MetadataStore.load(path);
seed.userID = USER_ID;
seed.collectionsSinceTime = 500;
seed.putCollection(collection(1, 400));
seed.putFile(file(1001, 1, 400));
await seed.save();
const client = new MockClient();
// The collection's updationTime advances (400 -> 600), so its files are
// re-enumerated from the collection's stored updationTime (400).
client.collectionsQueue.push({
collections: [collection(1, 600)],
deleted: [],
cursor: 600,
});
client.filesFor(1, {
files: [file(1002, 1, 550)],
deleted: [],
cursor: 550,
});
// Opening from an existing cache serves the seeded copy at once and
// refreshes in the background, so the refresh's effects are polled for.
const lib = await Library.open({ client, cacheDirectory });
try {
await vi.waitFor(
() => {
// Collections resumed from the stored cursor, and files were
// re-enumerated from the stored collection updationTime.
expect(client.collectionsSinceTimes[0]).toBe(500);
expect(client.filesCalls).toEqual([
{ collectionID: 1, sinceTime: 400 },
]);
expect(lib.listFiles(1).map((f) => f.id)).toEqual([
1001, 1002,
]);
},
{ timeout: 2000, interval: 5 },
);
} finally {
await lib.close();
}
});
it("does not re-enumerate a collection whose updationTime did not advance", async () => {
const path = join(cacheDirectory, "metadata.json");
const seed = await MetadataStore.load(path);
seed.userID = USER_ID;
seed.collectionsSinceTime = 100;
seed.putCollection(collection(1, 400));
await seed.save();
const client = new MockClient();
// The collection comes back in the diff (its metadata changed) but at
// the same updationTime, so its files must not be re-fetched.
client.collectionsQueue.push({
collections: [collection(1, 400, "renamed")],
deleted: [],
cursor: 400,
});
// Existing cache: the rename lands via the background refresh.
const lib = await Library.open({ client, cacheDirectory });
try {
await vi.waitFor(
() => expect(lib.getCollection(1)?.name).toBe("renamed"),
{ timeout: 2000, interval: 5 },
);
// The collection's updationTime did not advance, so its files were
// never re-fetched.
expect(client.filesCalls).toEqual([]);
} finally {
await lib.close();
}
});
it("applies diffs and tombstones on the interval", async () => {
const client = new MockClient();
client.collectionsQueue.push({
collections: [collection(1, 100), collection(2, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(1, {
files: [file(1001, 1, 90)],
deleted: [],
cursor: 90,
});
client.filesFor(2, {
files: [file(2001, 2, 90)],
deleted: [],
cursor: 90,
});
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: FAST_INTERVAL,
});
try {
expect(lib.listCollections().map((c) => c.id)).toEqual([1, 2]);
expect(lib.listFiles(2).map((f) => f.id)).toEqual([2001]);
// Next refresh: collection 2 is tombstoned; collection 1 gains a
// file and loses its old one.
client.filesFor(1, {
files: [file(1002, 1, 190)],
deleted: [1001],
cursor: 190,
});
client.collectionsQueue.push({
collections: [collection(1, 200)],
deleted: [2],
cursor: 200,
});
await vi.waitFor(
() => {
expect(lib.listCollections().map((c) => c.id)).toEqual([1]);
expect(lib.listFiles(1).map((f) => f.id)).toEqual([1002]);
// Collection 2's files went with it.
expect(lib.listFiles(2)).toEqual([]);
},
{ timeout: 2000, interval: 5 },
);
} finally {
await lib.close();
}
});
it("rewrites the cache only when a refresh changes something", async () => {
const saveSpy = vi.spyOn(MetadataStore.prototype, "save");
const client = new MockClient();
client.collectionsQueue.push({
collections: [collection(1, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(1, {
files: [file(1001, 1, 90)],
deleted: [],
cursor: 90,
});
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: FAST_INTERVAL,
});
try {
// The initial refresh changed everything, so it saved once.
expect(saveSpy).toHaveBeenCalledTimes(1);
// Several empty-diff ticks pass; none of them may rewrite the file.
await new Promise((r) => setTimeout(r, FAST_INTERVAL * 1000 * 4));
expect(saveSpy).toHaveBeenCalledTimes(1);
// A real change triggers exactly one more rewrite; later empty ticks
// still do not, so the count settles at two.
client.collectionsQueue.push({
collections: [collection(2, 200)],
deleted: [],
cursor: 200,
});
await vi.waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(2), {
timeout: 2000,
interval: 5,
});
await new Promise((r) => setTimeout(r, FAST_INTERVAL * 1000 * 4));
expect(saveSpy).toHaveBeenCalledTimes(2);
} finally {
await lib.close();
saveSpy.mockRestore();
}
});
it("keeps a failed refresh invisible to reads and recovers later", async () => {
const events: RefreshEvent[] = [];
const client = new MockClient();
client.collectionsQueue.push({
collections: [collection(1, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(1, {
files: [file(1001, 1, 90)],
deleted: [],
cursor: 90,
});
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: FAST_INTERVAL,
onProgress: (e) => events.push(e),
});
try {
expect(lib.listFiles(1).map((f) => f.id)).toEqual([1001]);
// The server goes away; refreshes now fail.
client.failCollections = true;
await vi.waitFor(
() => expect(lib.status().lastError).toMatch(/network down/),
{ timeout: 2000, interval: 5 },
);
// Reads still see the last good data; the failure was reported.
expect(lib.listFiles(1).map((f) => f.id)).toEqual([1001]);
expect(
events.some(
(e) => e.operation === "refresh" && e.status === "failed",
),
).toBe(true);
// Recovery: a later refresh succeeds and clears the error.
client.failCollections = false;
client.collectionsQueue.push({
collections: [collection(2, 300)],
deleted: [],
cursor: 300,
});
await vi.waitFor(
() => {
expect(lib.status().lastError).toBeUndefined();
expect(lib.listCollections().map((c) => c.id)).toEqual([
1, 2,
]);
},
{ timeout: 2000, interval: 5 },
);
} finally {
await lib.close();
}
});
it("resolves open() even when the first refresh fails", async () => {
const client = new MockClient();
client.failCollections = true;
const events: RefreshEvent[] = [];
const lib = await Library.open({
client,
cacheDirectory,
onProgress: (e) => events.push(e),
});
try {
// Nothing was cached and the server is unreachable: reads are empty,
// but the library opened and the failure is on record.
expect(lib.listCollections()).toEqual([]);
expect(lib.status().lastError).toMatch(/network down/);
expect(lib.status().lastRefreshAt).toBeUndefined();
expect(
events.some(
(e) => e.operation === "refresh" && e.status === "failed",
),
).toBe(true);
} finally {
await lib.close();
}
});
it("opens from an existing cache without waiting for the first refresh", async () => {
// Seed a cache as a prior run left it.
const path = join(cacheDirectory, "metadata.json");
const seed = await MetadataStore.load(path);
seed.userID = USER_ID;
seed.collectionsSinceTime = 500;
seed.putCollection(collection(1, 400));
seed.putFile(file(1001, 1, 400));
await seed.save();
// The server does not answer this run's first refresh until the test
// is done with it.
let answerFirstFetch: (page: CollectionsPage) => void = () => {};
const client = new MockClient();
client.collectionsSince = () =>
new Promise<CollectionsPage>((resolve) => {
answerFirstFetch = resolve;
});
// open() must resolve from the cache without blocking on the network,
// and reads must serve the seeded copy.
const lib = await Library.open({ client, cacheDirectory });
try {
expect(lib.listCollections().map((c) => c.id)).toEqual([1]);
expect(lib.listFiles(1).map((f) => f.id)).toEqual([1001]);
// The first refresh is still outstanding: nothing has completed or
// failed yet.
expect(lib.status().lastRefreshAt).toBeUndefined();
expect(lib.status().lastError).toBeUndefined();
} finally {
// close() waits for the outstanding refresh, so let it finish.
answerFirstFetch({ collections: [], deleted: [], cursor: 500 });
await lib.close();
}
});
it("awaits the first refresh on a first run with an empty cache", async () => {
// No cache on disk: open() must not resolve until the first fetch does,
// so it never hands back an empty library it could have filled.
let releaseFirstFetch: (page: CollectionsPage) => void = () => {};
const gate = new Promise<CollectionsPage>((resolve) => {
releaseFirstFetch = resolve;
});
const client = new MockClient();
client.filesFor(1, {
files: [file(1001, 1, 90)],
deleted: [],
cursor: 90,
});
client.collectionsSince = async (args: { sinceTime: number }) => {
client.collectionsSinceTimes.push(args.sinceTime);
return gate;
};
let opened = false;
const openPromise = Library.open({ client, cacheDirectory }).then(
(l) => {
opened = true;
return l;
},
);
// While the first fetch is outstanding, open() has not resolved.
await new Promise((r) => setTimeout(r, 20));
expect(opened).toBe(false);
// Completing the fetch lets open() resolve with the data in place.
releaseFirstFetch({
collections: [collection(1, 100)],
deleted: [],
cursor: 100,
});
const lib = await openPromise;
try {
expect(opened).toBe(true);
expect(lib.listCollections().map((c) => c.id)).toEqual([1]);
expect(lib.listFiles(1).map((f) => f.id)).toEqual([1001]);
expect(lib.status().lastRefreshAt).toBeGreaterThan(0);
} finally {
await lib.close();
}
});
it("keeps a save failure visible until a save actually succeeds", async () => {
const saveSpy = vi
.spyOn(MetadataStore.prototype, "save")
.mockRejectedValue(new Error("disk full"));
const client = new MockClient();
client.collectionsQueue.push({
collections: [collection(1, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(1, {
files: [file(1001, 1, 90)],
deleted: [],
cursor: 90,
});
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: FAST_INTERVAL,
});
try {
// The initial refresh mutated RAM but its save failed, so the error
// is on record and no refresh has counted as successful.
expect(lib.status().lastError).toMatch(/disk full/);
expect(lib.status().lastRefreshAt).toBeUndefined();
// Empty-diff ticks pass. Each still retries the unsaved write and
// still fails, so the error never silently clears and the refresh
// clock never advances — RAM must not run ahead of disk unnoticed.
const savesBefore = saveSpy.mock.calls.length;
await new Promise((r) => setTimeout(r, FAST_INTERVAL * 1000 * 4));
expect(saveSpy.mock.calls.length).toBeGreaterThan(savesBefore);
expect(lib.status().lastError).toMatch(/disk full/);
expect(lib.status().lastRefreshAt).toBeUndefined();
// Once the disk recovers, the next tick persists the pending change
// and only then clears the error and advances the clock.
saveSpy.mockRestore();
await vi.waitFor(
() => {
expect(lib.status().lastError).toBeUndefined();
expect(lib.status().lastRefreshAt).toBeGreaterThan(0);
},
{ timeout: 2000, interval: 5 },
);
const reloaded = await MetadataStore.load(
join(cacheDirectory, "metadata.json"),
);
expect(reloaded.getFile(1, 1001)?.id).toBe(1001);
} finally {
await lib.close();
saveSpy.mockRestore();
}
});
it("close() stops the timer and is idempotent", async () => {
const client = new MockClient();
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: FAST_INTERVAL,
});
const callsAfterOpen = client.collectionsSinceTimes.length;
await lib.close();
await lib.close(); // second close must not throw
expect(lib.status().closed).toBe(true);
// No further refreshes fire once closed.
await new Promise((r) => setTimeout(r, FAST_INTERVAL * 1000 * 5));
expect(client.collectionsSinceTimes.length).toBe(callsAfterOpen);
});
it("close() resolves only after an in-flight refresh has written the cache", async () => {
const path = join(cacheDirectory, "metadata.json");
const seed = await MetadataStore.load(path);
seed.userID = USER_ID;
seed.collectionsSinceTime = 500;
seed.putCollection(collection(1, 400));
seed.putFile(file(1001, 1, 400));
await seed.save();
const client = new MockClient();
client.collectionsQueue.push({
collections: [collection(1, 600)],
deleted: [],
cursor: 600,
});
client.filesFor(1, {
files: [file(1002, 1, 600)],
deleted: [],
cursor: 600,
});
// Hold the refresh's cache write until the test releases it.
const realSave = MetadataStore.prototype.save;
let releaseSave: () => void = () => {};
const saveHeld = new Promise<void>((resolve) => {
releaseSave = resolve;
});
const saveSpy = vi
.spyOn(MetadataStore.prototype, "save")
.mockImplementation(async function (this: MetadataStore) {
await saveHeld;
return realSave.call(this);
});
const lib = await Library.open({ client, cacheDirectory });
try {
await vi.waitFor(() => expect(saveSpy).toHaveBeenCalled(), {
timeout: 2000,
interval: 5,
});
let closed = false;
const closing = lib.close().then(() => {
closed = true;
});
await new Promise((r) => setTimeout(r, 50));
expect(closed).toBe(false);
releaseSave();
await closing;
const reloaded = await MetadataStore.load(path);
expect(reloaded.getFile(1, 1002)?.id).toBe(1002);
} finally {
releaseSave();
await lib.close();
saveSpy.mockRestore();
}
});
it("defaults cacheDirectory to the env-paths cache dir plus user id", async () => {
const xdg = join(dir, "xdg-cache");
const prev = process.env.XDG_CACHE_HOME;
process.env.XDG_CACHE_HOME = xdg;
try {
const client = new MockClient();
const lib = await Library.open({ client });
try {
const expected = join(
envPaths("quak", { suffix: "" }).cache,
String(USER_ID),
);
expect(lib.cacheDirectory).toBe(expected);
expect(lib.cacheDirectory.startsWith(xdg)).toBe(true);
expect(lib.cacheDirectory.endsWith(String(USER_ID))).toBe(true);
} finally {
await lib.close();
}
} finally {
if (prev === undefined) delete process.env.XDG_CACHE_HOME;
else process.env.XDG_CACHE_HOME = prev;
}
});
});
+501
View File
@@ -0,0 +1,501 @@
/**
* Tests for the ML-data cache and its derived CLIP index (issue #49).
*
* Two layers are exercised:
*
* 1. `MLDataStore` on its own: storing one payload file per fileID (present
* means complete), building a `clip.f32` + `clip.json` index that reloads
* in a single read, rebuilding that index from the payloads when it is
* missing or disagrees with the files present, appending as new payloads
* arrive, overwriting a refetched file in place, and deciding what to
* (re)fetch as `updationTime` advances.
*
* 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.
*
* Embedding values are chosen to be exactly representable as float32 so the
* round-trip through `clip.f32` compares equal.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
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 type { CollectionsPage, FilesPage } from "../../src/client.js";
import type { MLData } from "../../src/mldata-fetch.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
// A payload shaped like Ente's: a CLIP embedding plus face data that only the
// on-disk payload carries (never the RAM index).
const payload = (embedding: number[]): MLData => ({
face: {
faces: [{ faceID: "f", detection: { box: { x: 0.5 } } }],
},
clip: { embedding },
});
describe("MLDataStore", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "quak-mldata-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("stores one payload file per fileID and builds a one-read index", async () => {
const store = await MLDataStore.open(dir);
const res = await store.storeFetched(
new Map([
[100, payload([0.5, 0.25, 0.75])],
[200, payload([1, -2, 0.5])],
]),
new Map([
[100, 10],
[200, 20],
]),
);
expect(res).toEqual({ stored: 2, indexed: 2 });
// One payload file per fileID, and the derived index files.
expect(existsSync(join(dir, "100.json"))).toBe(true);
expect(existsSync(join(dir, "200.json"))).toBe(true);
expect(existsSync(join(dir, "clip.f32"))).toBe(true);
expect(existsSync(join(dir, "clip.json"))).toBe(true);
// Reopening loads the index from disk in one read.
const reopened = await MLDataStore.open(dir);
const index = reopened.getIndex();
expect(index.fileIDs).toEqual([100, 200]);
expect(index.embeddingLength).toBe(3);
expect([...index.embeddings]).toEqual([0.5, 0.25, 0.75, 1, -2, 0.5]);
// The full payload (face boxes) is read back from disk on demand.
const full = await reopened.readPayload(100);
expect(full?.face).toBeDefined();
expect(await reopened.readPayload(999)).toBeUndefined();
});
it("rebuilds the index from payloads when it is missing", async () => {
const store = await MLDataStore.open(dir);
await store.storeFetched(
new Map([[100, payload([0.5, 0.25, 0.75])]]),
new Map([[100, 10]]),
);
// The derived index is lost but the payloads survive.
rmSync(join(dir, "clip.f32"));
rmSync(join(dir, "clip.json"));
const reopened = await MLDataStore.open(dir);
const index = reopened.getIndex();
expect(index.fileIDs).toEqual([100]);
expect([...index.embeddings]).toEqual([0.5, 0.25, 0.75]);
expect(existsSync(join(dir, "clip.f32"))).toBe(true);
});
it("rebuilds the index when it disagrees with the files present", async () => {
const store = await MLDataStore.open(dir);
await store.storeFetched(
new Map([
[100, payload([0.5, 0.25, 0.75])],
[200, payload([1, -2, 0.5])],
]),
new Map([
[100, 10],
[200, 20],
]),
);
// A payload disappears out from under the index (leaving it referencing
// a file no longer present); the index must be rebuilt from what is
// actually on disk.
rmSync(join(dir, "200.json"));
const reopened = await MLDataStore.open(dir);
expect(reopened.getIndex().fileIDs).toEqual([100]);
});
it("rebuilds the index when a payload on disk is missing from it", async () => {
const store = await MLDataStore.open(dir);
await store.storeFetched(
new Map([[100, payload([0.5, 0.25, 0.75])]]),
new Map([[100, 10]]),
);
// A crash between storeFetched renaming a payload into place and
// rewriting the index leaves the payload complete on disk but absent
// from clip.json. Write a second payload directly to reproduce that
// torn state without touching the index.
writeFileSync(
join(dir, "200.json"),
JSON.stringify(payload([1, -2, 0.5])),
);
// Reopening self-heals with no manual delete: the index is rebuilt from
// the payloads to include the orphaned embedding.
const reopened = await MLDataStore.open(dir);
const index = reopened.getIndex();
expect(index.fileIDs).toEqual([100, 200]);
expect([...index.embeddings]).toEqual([0.5, 0.25, 0.75, 1, -2, 0.5]);
});
it("appends new payloads and overwrites a refetched file in place", async () => {
const store = await MLDataStore.open(dir);
await store.storeFetched(
new Map([[100, payload([0.5, 0.25, 0.75])]]),
new Map([[100, 10]]),
);
// A later batch adds a new file: appended after the first.
await store.storeFetched(
new Map([[200, payload([1, -2, 0.5])]]),
new Map([[200, 20]]),
);
// Refetching 100 (its embedding changed) updates it in place, not a
// duplicate row.
await store.storeFetched(
new Map([[100, payload([9, 9, 9])]]),
new Map([[100, 30]]),
);
const index = store.getIndex();
expect(index.fileIDs).toEqual([100, 200]);
expect([...index.embeddings]).toEqual([9, 9, 9, 1, -2, 0.5]);
});
it("keeps a payload without a CLIP embedding out of the index", async () => {
const store = await MLDataStore.open(dir);
const res = await store.storeFetched(
new Map<number, MLData>([[100, { face: { faces: [] } }]]),
new Map([[100, 10]]),
);
expect(res.stored).toBe(1);
expect(res.indexed).toBe(0);
// The payload is still cached (present means complete).
expect(existsSync(join(dir, "100.json"))).toBe(true);
expect(store.getIndex().fileIDs).toEqual([]);
});
it("fetches only what is missing or has a newer updationTime", async () => {
const store = await MLDataStore.open(dir);
await store.storeFetched(
new Map([[100, payload([0.5, 0.25, 0.75])]]),
new Map([[100, 10]]),
);
// 100 is cached and current; 200 has never been fetched.
expect(
store.neededFor([
{ id: 100, updationTime: 10 },
{ id: 200, updationTime: 5 },
]),
).toEqual([200]);
// 100's updationTime advanced past what it was fetched at: refetch.
expect(store.neededFor([{ id: 100, updationTime: 15 }])).toEqual([100]);
// Nothing advanced: nothing to fetch.
expect(store.neededFor([{ id: 100, updationTime: 10 }])).toEqual([]);
});
it("survives a corrupt index without losing the payloads", async () => {
const store = await MLDataStore.open(dir);
await store.storeFetched(
new Map([[100, payload([0.5, 0.25, 0.75])]]),
new Map([[100, 10]]),
);
writeFileSync(join(dir, "clip.json"), "not json");
const reopened = await MLDataStore.open(dir);
expect(reopened.getIndex().fileIDs).toEqual([100]);
});
});
// --- Library wiring ---------------------------------------------------------
const USER_ID = 42;
const FAST_INTERVAL = 0.02;
const collection = (id: number, updationTime: number): Collection => ({
id,
ownerID: USER_ID,
key: new Uint8Array([id & 0xff]),
name: `album-${id}`,
type: "album",
updationTime,
isShared: false,
});
const file = (
id: number,
collectionID: number,
updationTime: number,
): EnteFile => ({
id,
collectionID,
ownerID: USER_ID,
key: new Uint8Array([id & 0xff]),
metadata: {
title: `file-${id}.jpg`,
fileType: "image",
creationTime: updationTime,
modificationTime: updationTime,
},
file: { decryptionHeader: "aGVhZGVy" },
thumbnail: { decryptionHeader: "dGh1bWI=" },
updationTime,
});
// A mock client that serves scripted collection/file pages and per-file ML
// payloads, recording every ML fetch request so incremental behaviour is
// provable.
class MLMockClient {
userID = USER_ID;
collectionsQueue: CollectionsPage[] = [];
filesByCollection = new Map<number, FilesPage[]>();
mlByFile = new Map<number, MLData>();
mlFetchCalls: number[][] = [];
whoami(): { email: string; userID: number } {
return { email: "user@example.com", userID: this.userID };
}
async collectionsSince(args: {
sinceTime: number;
}): Promise<CollectionsPage> {
return (
this.collectionsQueue.shift() ?? {
collections: [],
deleted: [],
cursor: args.sinceTime,
}
);
}
async filesSince(args: {
collectionID: number;
collectionKey: Uint8Array;
sinceTime: number;
}): Promise<FilesPage> {
const queue = this.filesByCollection.get(args.collectionID);
return (
queue?.shift() ?? {
files: [],
deleted: [],
cursor: args.sinceTime,
}
);
}
async fetchMLData(args: {
fileIDs: number[];
fileKeys: Map<number, Uint8Array>;
}): Promise<Map<number, MLData>> {
this.mlFetchCalls.push([...args.fileIDs]);
const result = new Map<number, MLData>();
for (const id of args.fileIDs) {
const p = this.mlByFile.get(id);
if (p) result.set(id, p);
}
return result;
}
filesFor(collectionID: number, ...pages: FilesPage[]): void {
this.filesByCollection.set(collectionID, pages);
}
}
describe("Library ML-data fetch on refresh", () => {
let dir: string;
let cacheDirectory: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "quak-lib-mldata-"));
cacheDirectory = join(dir, "cache");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("fetches, stores and indexes ML data for known files, then is incremental", 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.mlByFile.set(1002, payload([1, -2, 0.5]));
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: FAST_INTERVAL,
});
try {
// Wait on `lastMLFetchAt`, set only once the pass has persisted the
// index and payloads — not on the in-RAM counts, which advance
// before `storeFetched` writes to disk, so the reopen below reads
// the committed index rather than racing the write.
await vi.waitFor(
() => {
expect(lib.status().lastMLFetchAt).toBeGreaterThan(0);
expect(lib.status().mlIndexed).toBe(2);
expect(lib.status().mlStored).toBe(2);
},
{ timeout: 2000, interval: 5 },
);
// Both files were fetched, in one batch.
expect(client.mlFetchCalls.flat().sort((a, b) => a - b)).toEqual([
1001, 1002,
]);
const callsAfterFirst = client.mlFetchCalls.length;
// The index is on disk and reloads to the same shape.
const reopened = await MLDataStore.open(
join(cacheDirectory, "mldata"),
);
expect(reopened.getIndex().fileIDs).toEqual([1001, 1002]);
// Later refreshes with nothing new must not refetch.
await new Promise((r) => setTimeout(r, FAST_INTERVAL * 1000 * 5));
expect(client.mlFetchCalls.length).toBe(callsAfterFirst);
} finally {
await lib.close();
}
});
it("refetches a file whose updationTime advanced", async () => {
const client = new MLMockClient();
client.collectionsQueue.push({
collections: [collection(1, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(1, {
files: [file(1001, 1, 90)],
deleted: [],
cursor: 90,
});
client.mlByFile.set(1001, payload([0.5, 0.25, 0.75]));
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: FAST_INTERVAL,
});
try {
// Wait on `lastMLFetchAt`, set only after the first pass has
// persisted, not on `mlIndexed`, which is bumped in RAM before the
// write lands.
await vi.waitFor(
() => expect(lib.status().lastMLFetchAt).toBeGreaterThan(0),
{ timeout: 2000, interval: 5 },
);
const callsBefore = client.mlFetchCalls.length;
// The file changes on the server (updationTime advances) with a new
// embedding; the next refresh must refetch it.
client.mlByFile.set(1001, payload([9, 9, 9]));
client.collectionsQueue.push({
collections: [collection(1, 200)],
deleted: [],
cursor: 200,
});
client.filesFor(1, {
files: [file(1001, 1, 190)],
deleted: [],
cursor: 190,
});
// Poll the persisted index itself, not the fetch-call log: a call
// is recorded the instant the mock is entered, but `storeFetched`
// rewrites `clip.f32` only after it resolves, so an earlier reopen
// would read the pre-refetch vector. Reopening reads only committed
// (atomically renamed) files, so this sees the new embedding once —
// and only once — the store has written it.
await vi.waitFor(
async () => {
const reopened = await MLDataStore.open(
join(cacheDirectory, "mldata"),
);
expect([...reopened.getIndex().embeddings]).toEqual([
9, 9, 9,
]);
},
{ timeout: 2000, interval: 20 },
);
// The refetch really went back to the server for 1001.
expect(client.mlFetchCalls.length).toBeGreaterThan(callsBefore);
expect(client.mlFetchCalls.flat()).toContain(1001);
} finally {
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({
collections: [collection(1, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(1, {
files: [file(1001, 1, 90)],
deleted: [],
cursor: 90,
});
client.mlByFile.set(1001, payload([0.5, 0.25, 0.75]));
// Hold the ML data fetch open until the test releases it.
let release!: () => void;
const held = new Promise<void>((r) => (release = r));
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);
};
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: 3600,
});
try {
await started;
let closed = false;
const closing = lib.close().then(() => {
closed = true;
});
await new Promise((r) => setTimeout(r, 20));
expect(closed).toBe(false);
release();
await closing;
expect(
existsSync(join(cacheDirectory, "mldata", "1001.json")),
).toBe(true);
} finally {
release();
await lib.close();
}
});
});
+108
View File
@@ -0,0 +1,108 @@
/**
* Tests for the content-similarity search surface over the CLIP index
* (issue #50).
*
* The surface is `lib.mldata`: `forFile` reads the full stored payload from
* disk, while `similar` and `searchByEmbedding` rank fileIDs by cosine
* similarity over the in-RAM `Float32Array` index alone (no disk, no network).
* The fixture uses axis-aligned vectors so the correct cosine ranking is
* obvious by inspection; cosine ignores magnitude, so `[2, 0, 0]` ranks above
* `[0.8, 0.6, 0]` for a `[1, 0, 0]` query.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { MLDataStore } from "../../src/library/mldata.js";
import { makeMLDataAPI, type MLDataAPI } from "../../src/library/mlsearch.js";
import type { MLData } from "../../src/mldata-fetch.js";
// A payload shaped like Ente's: a CLIP embedding plus face data that only the
// on-disk payload carries (never the RAM index).
const payload = (embedding: number[]): MLData => ({
face: { faces: [{ faceID: "f", detection: { box: { x: 0.5 } } }] },
clip: { embedding },
});
// A small fixture index. Directions are chosen so every cosine ranking below
// is unambiguous.
const fixture = (): Map<number, MLData> =>
new Map([
[10, payload([1, 0, 0])],
[20, payload([0.8, 0.6, 0])],
[30, payload([0, 1, 0])],
[40, payload([-1, 0, 0])],
[50, payload([2, 0, 0])],
]);
describe("lib.mldata content-similarity search", () => {
let dir: string;
let store: MLDataStore;
let api: MLDataAPI;
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), "quak-mlsearch-"));
store = await MLDataStore.open(dir);
const updation = new Map([...fixture().keys()].map((id) => [id, 1]));
await store.storeFetched(fixture(), updation);
api = makeMLDataAPI(() => store);
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("forFile returns the whole stored payload, or undefined when uncached", async () => {
const full = await api.forFile({ fileID: 20 });
expect(full).toBeDefined();
// Face data lives only in the payload, never in the RAM index.
expect(full?.face).toBeDefined();
expect(full?.clip).toEqual({ embedding: [0.8, 0.6, 0] });
expect(await api.forFile({ fileID: 999 })).toBeUndefined();
});
it("similar ranks other files by cosine and excludes the query itself", () => {
// Query is file 10 = [1, 0, 0]. By cosine: 50 (1.0) > 20 (0.8) >
// 30 (0) > 40 (-1); 10 itself is left out.
const ranked = api.similar({ fileID: 10 });
expect(ranked.map((r) => r.fileID)).toEqual([50, 20, 30, 40]);
// Cosine ignores magnitude: [2,0,0] is a perfect match for [1,0,0].
expect(ranked[0]).toMatchObject({ fileID: 50 });
expect(ranked[0].score).toBeCloseTo(1, 5);
});
it("similar honours limit and returns [] for an unindexed file", () => {
expect(
api.similar({ fileID: 10, limit: 2 }).map((r) => r.fileID),
).toEqual([50, 20]);
expect(api.similar({ fileID: 999 })).toEqual([]);
});
it("searchByEmbedding ranks the index by cosine to the query vector", () => {
// Query [0, 1, 0]: 30 (1.0) > 20 (0.6) > {10, 40, 50} all 0, broken by
// ascending fileID.
const ranked = api.searchByEmbedding({ embedding: [0, 1, 0] });
expect(ranked.map((r) => r.fileID)).toEqual([30, 20, 10, 40, 50]);
expect(ranked[0].score).toBeCloseTo(1, 5);
expect(
api
.searchByEmbedding({ embedding: [0, 1, 0], limit: 2 })
.map((r) => r.fileID),
).toEqual([30, 20]);
});
it("searchByEmbedding returns [] for a wrong-length or zero query", () => {
expect(api.searchByEmbedding({ embedding: [1, 0] })).toEqual([]);
expect(api.searchByEmbedding({ embedding: [0, 0, 0] })).toEqual([]);
});
it("degrades to empty results when no ML store is present", async () => {
const none = makeMLDataAPI(() => undefined);
expect(await none.forFile({ fileID: 10 })).toBeUndefined();
expect(none.similar({ fileID: 10 })).toEqual([]);
expect(none.searchByEmbedding({ embedding: [1, 0, 0] })).toEqual([]);
});
});
+345
View File
@@ -0,0 +1,345 @@
/**
* Tests for `src/library/pools.ts` the three bounded request pools (issue
* #45).
*
* A `BoundedPool` runs submitted tasks with a fixed concurrency cap. Within a
* pool, on-demand work runs before background work, and a task submitted with a
* key that a still-pending task already carries is not run twice both callers
* share the one result. `RequestPools` bundles the three the design calls for
* (metadata 10, content 5, thumbnails 25); the pools are independent, so an
* idle pool never lends its slots to a busy one.
*
* ## How the tasks are controlled
*
* Every task here is a gate: it reports when it *starts* and then blocks until
* the test *releases* it, so the test decides exactly how many run at once and
* in what order they finish. A shared tracker counts how many tasks are running
* at any instant and records the peak, which is what the concurrency assertions
* read. No assertion is about wall-clock time.
*
* `drain()` returns a promise that settles on a macrotask, which flushes the
* microtask queue the pool schedules its starts on; the tests await it to let
* the pool react to a submission or a release before they inspect it.
*/
import { describe, it, expect } from "vitest";
import {
BoundedPool,
RequestPools,
DEFAULT_METADATA_CONCURRENCY,
DEFAULT_CONTENT_CONCURRENCY,
DEFAULT_THUMBNAIL_CONCURRENCY,
} from "../../src/library/pools.js";
// Settle on a macrotask so every microtask the pool queued has run.
const drain = (): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, 0));
// A controllable task. `task` blocks until `release()` (resolve) or `fail()`
// (reject) is called; `startOrder` records the sequence in which tasks began.
interface Gate<T> {
task: () => Promise<T>;
release: (value: T) => void;
fail: (err: unknown) => void;
started: () => boolean;
runs: () => number;
}
// Tracks how many gated tasks are running concurrently across a whole test.
class Tracker {
active = 0;
peak = 0;
readonly starts: string[] = [];
gate<T>(label = ""): Gate<T> {
let settleResolve!: (value: T) => void;
let settleReject!: (err: unknown) => void;
const settled = new Promise<T>((resolve, reject) => {
settleResolve = resolve;
settleReject = reject;
});
let started = false;
let runs = 0;
const task = async (): Promise<T> => {
started = true;
runs++;
this.active++;
this.peak = Math.max(this.peak, this.active);
this.starts.push(label);
try {
return await settled;
} finally {
this.active--;
}
};
return {
task,
release: (value: T) => settleResolve(value),
fail: (err: unknown) => settleReject(err),
started: () => started,
runs: () => runs,
};
}
}
describe("BoundedPool concurrency cap", () => {
it("never runs more than `concurrency` tasks at once", async () => {
const pool = new BoundedPool(3);
const t = new Tracker();
const gates = Array.from({ length: 5 }, () => t.gate<void>());
const done = gates.map((g) => pool.run(g.task));
await drain();
// Three started, two queued behind the cap.
expect(t.active).toBe(3);
expect(gates.slice(0, 3).every((g) => g.started())).toBe(true);
expect(gates.slice(3).some((g) => g.started())).toBe(false);
// Finishing one admits exactly one more; the cap holds.
gates[0]!.release();
await drain();
expect(t.active).toBe(3);
expect(gates[3]!.started()).toBe(true);
expect(gates[4]!.started()).toBe(false);
for (const g of gates.slice(1)) g.release();
await Promise.all(done);
expect(t.peak).toBe(3);
});
it("rejects a non-positive or non-integer concurrency", () => {
expect(() => new BoundedPool(0)).toThrow(RangeError);
expect(() => new BoundedPool(-1)).toThrow(RangeError);
expect(() => new BoundedPool(2.5)).toThrow(RangeError);
});
});
describe("BoundedPool priority ordering", () => {
it("runs on-demand work before background, FIFO within a priority", async () => {
const pool = new BoundedPool(1);
const t = new Tracker();
const a = t.gate<void>("a");
const b = t.gate<void>("b");
const c = t.gate<void>("c");
const d = t.gate<void>("d");
// `a` takes the only slot; the rest queue.
void pool.run(a.task, { priority: "background" });
await drain();
void pool.run(b.task, { priority: "background" });
void pool.run(c.task, { priority: "on-demand" });
void pool.run(d.task, { priority: "background" });
await drain();
expect(t.starts).toEqual(["a"]);
// The on-demand `c` jumps ahead of the earlier-queued background `b`.
a.release();
await drain();
expect(t.starts).toEqual(["a", "c"]);
// Then background work drains in submission order: `b` before `d`.
c.release();
await drain();
expect(t.starts).toEqual(["a", "c", "b"]);
b.release();
await drain();
expect(t.starts).toEqual(["a", "c", "b", "d"]);
d.release();
});
it("defaults to background priority", async () => {
const pool = new BoundedPool(1);
const t = new Tracker();
const a = t.gate<void>("a");
const plain = t.gate<void>("plain");
const urgent = t.gate<void>("urgent");
void pool.run(a.task);
await drain();
void pool.run(plain.task); // no options -> background
void pool.run(urgent.task, { priority: "on-demand" });
await drain();
a.release();
await drain();
expect(t.starts).toEqual(["a", "urgent"]);
urgent.release();
plain.release();
});
});
describe("BoundedPool in-flight dedup", () => {
it("fetches a key once and hands both callers the same result", async () => {
const pool = new BoundedPool(5);
const t = new Tracker();
const g = t.gate<number>();
const first = pool.run(g.task, { key: 7 });
const second = pool.run(g.task, { key: 7 });
await drain();
expect(g.runs()).toBe(1);
expect(first).toBe(second);
g.release(99);
expect(await first).toBe(99);
expect(await second).toBe(99);
});
it("dedups only while in flight; a settled key runs again", async () => {
const pool = new BoundedPool(5);
const t = new Tracker();
const g1 = t.gate<number>();
const first = pool.run(g1.task, { key: 7 });
await drain();
g1.release(1);
expect(await first).toBe(1);
// The key is free again once its task settled.
const g2 = t.gate<number>();
const third = pool.run(g2.task, { key: 7 });
await drain();
expect(g2.started()).toBe(true);
g2.release(2);
expect(await third).toBe(2);
});
it("propagates a rejection to every deduped caller", async () => {
const pool = new BoundedPool(5);
const t = new Tracker();
const g = t.gate<number>();
const first = pool.run(g.task, { key: 7 });
const second = pool.run(g.task, { key: 7 });
await drain();
const boom = new Error("boom");
g.fail(boom);
await expect(first).rejects.toBe(boom);
await expect(second).rejects.toBe(boom);
// A failed key is also freed, so it may be retried by a fresh submit.
const g2 = t.gate<number>();
const retry = pool.run(g2.task, { key: 7 });
await drain();
expect(g2.started()).toBe(true);
g2.release(5);
expect(await retry).toBe(5);
});
});
describe("BoundedPool slot lifetime", () => {
it("holds one slot for a task's whole lifetime, retries included", async () => {
const pool = new BoundedPool(1);
const t = new Tracker();
// A task that internally makes two attempts before succeeding — the
// shape of a retrying request. It must occupy exactly one slot for the
// whole of that, so no other task may start until it finally settles.
const attempt1 = t.gate<void>("attempt1");
const attempt2 = t.gate<void>("attempt2");
const retrying = async (): Promise<void> => {
try {
await attempt1.task();
} catch {
await attempt2.task();
}
};
const other = t.gate<void>("other");
const running = pool.run(retrying);
await drain();
void pool.run(other.task);
await drain();
// First attempt is in flight and holds the only slot.
expect(t.starts).toEqual(["attempt1"]);
expect(other.started()).toBe(false);
// The retry is still the same task in the same slot; `other` waits.
attempt1.fail(new Error("transient"));
await drain();
expect(t.starts).toEqual(["attempt1", "attempt2"]);
expect(other.started()).toBe(false);
// Only when the whole task settles does the slot free.
attempt2.release();
await running;
await drain();
expect(other.started()).toBe(true);
other.release();
});
});
describe("RequestPools", () => {
it("exposes three pools at the design's default caps", () => {
expect(DEFAULT_METADATA_CONCURRENCY).toBe(10);
expect(DEFAULT_CONTENT_CONCURRENCY).toBe(5);
expect(DEFAULT_THUMBNAIL_CONCURRENCY).toBe(25);
const pools = new RequestPools();
expect(pools.metadata.concurrency).toBe(10);
expect(pools.content.concurrency).toBe(5);
expect(pools.thumbnails.concurrency).toBe(25);
});
it("takes overridden caps", () => {
const pools = new RequestPools({
metadataConcurrency: 1,
contentConcurrency: 2,
thumbnailConcurrency: 3,
});
expect(pools.metadata.concurrency).toBe(1);
expect(pools.content.concurrency).toBe(2);
expect(pools.thumbnails.concurrency).toBe(3);
});
it("keeps pools independent: an idle pool lends no slots", async () => {
const pools = new RequestPools({ contentConcurrency: 1 });
const t = new Tracker();
const c1 = t.gate<void>();
const c2 = t.gate<void>();
const c3 = t.gate<void>();
// The content pool is capped at 1. The thumbnail pool sits idle with 25
// free slots — none of which may be borrowed to run a second content
// task.
void pools.content.run(c1.task);
void pools.content.run(c2.task);
void pools.content.run(c3.task);
await drain();
expect(t.active).toBe(1);
c1.release();
await drain();
expect(t.active).toBe(1);
c2.release();
await drain();
expect(t.active).toBe(1);
c3.release();
await drain();
expect(t.peak).toBe(1);
});
it("runs different pools concurrently", async () => {
const pools = new RequestPools({
metadataConcurrency: 1,
contentConcurrency: 1,
});
const t = new Tracker();
const m = t.gate<void>();
const c = t.gate<void>();
void pools.metadata.run(m.task);
void pools.content.run(c.task);
await drain();
// One slot each, in two independent pools: both run at once.
expect(t.active).toBe(2);
m.release();
c.release();
});
});
+492
View File
@@ -0,0 +1,492 @@
/**
* The aggressive local precache (issue #48), driven from `Library.open`.
*
* Two background fills start with no caller input: every thumbnail in the
* account newest first, and the originals of the pinned set (the favorites
* album then the latest `precacheOriginalsDays` window ending at the newest
* file). Both run through the shared pools at background priority, so on-demand
* work always preempts them; both report through `status()`. The pinned set is
* the eviction predicate (#47), so a pinned original is never evicted and a
* file that leaves the set becomes an ordinary, evictable original.
*
* The unit tests drive `Precache` against a fake cache that records what it was
* asked to fetch (order and kind) with no pool or network; the integration
* tests drive the real wiring through `Library.open` with a stub content
* source, and the eviction test drives the real `ContentCache`.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, existsSync, utimesSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Precache, type PrecacheCache } from "../../src/library/precache.js";
import {
deriveRecords,
type DerivedRecords,
} from "../../src/library/records.js";
import {
ContentCache,
type ContentSource,
type EnsureResult,
type StatFsFn,
} from "../../src/library/content.js";
import { RequestPools } from "../../src/library/pools.js";
import { Library } from "../../src/library/index.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js";
const DAY_MICROS = 24 * 60 * 60 * 1000 * 1000;
const collection = (
id: number,
type: Collection["type"] = "album",
): Collection => ({
id,
ownerID: 1,
key: new Uint8Array([id & 0xff]),
name: `album-${id}`,
type,
updationTime: 1,
isShared: false,
});
// A file whose creationTime (microseconds) places it `daysAgo` days before a
// fixed reference instant, so the latest-week window is deterministic.
const REFERENCE_MICROS = 1_000 * DAY_MICROS;
const file = (id: number, collectionID: number, daysAgo: number): EnteFile => ({
id,
collectionID,
ownerID: 1,
key: new Uint8Array([id & 0xff]),
metadata: {
title: `file-${id}.jpg`,
fileType: "image",
creationTime: REFERENCE_MICROS - daysAgo * DAY_MICROS,
modificationTime: 0,
},
file: { decryptionHeader: "aGVhZGVy" },
thumbnail: { decryptionHeader: "dGh1bWI=" },
updationTime: 1,
});
const records = (
collections: Collection[],
files: EnteFile[],
): DerivedRecords => deriveRecords(collections, files);
// A fake cache: records every fetch (kind + order) and reports the files it has
// stored via `pathsFor`. `presentThumbs`/`presentOriginals` seed already-cached
// files so the precache skips them with a single lookup.
class FakeCache implements PrecacheCache {
readonly thumbFetched: number[] = [];
readonly originalFetched: number[] = [];
readonly presentThumbs = new Set<number>();
readonly presentOriginals = new Set<number>();
pathsFor(fileID: number): {
originalPath?: string;
thumbnailPath?: string;
} {
const out: { originalPath?: string; thumbnailPath?: string } = {};
if (this.presentThumbs.has(fileID))
out.thumbnailPath = `/thumbs/${fileID}`;
if (this.presentOriginals.has(fileID))
out.originalPath = `/originals/${fileID}`;
return out;
}
async ensureThumbnails(args: {
fileIDs: number[];
priority: "background";
}): Promise<EnsureResult[]> {
return args.fileIDs.map((fileID) => {
this.thumbFetched.push(fileID);
this.presentThumbs.add(fileID);
return { fileID, path: `/thumbs/${fileID}` };
});
}
async ensureOriginals(args: {
fileIDs: number[];
}): Promise<EnsureResult[]> {
return args.fileIDs.map((fileID) => {
this.originalFetched.push(fileID);
this.presentOriginals.add(fileID);
return { fileID, path: `/originals/${fileID}` };
});
}
}
// Resolve once a predicate holds, polling the microtask queue; fails fast
// rather than hanging the suite.
const until = async (predicate: () => boolean): Promise<void> => {
for (let i = 0; i < 1000; i++) {
if (predicate()) return;
await new Promise((r) => setTimeout(r, 1));
}
throw new Error("condition not met in time");
};
describe("Precache unit", () => {
it("precaches every thumbnail newest first, skipping present ones", async () => {
const cols = [collection(1)];
const files = [
file(1, 1, 0),
file(2, 1, 1),
file(3, 1, 2),
file(4, 1, 3),
];
const cache = new FakeCache();
cache.presentThumbs.add(3); // already on disk: skipped
const pre = new Precache({ originals: false });
pre.bind(cache);
pre.update(records(cols, files));
pre.start();
await until(() => cache.thumbFetched.length === 3);
// Newest first (file 1 is newest), file 3 skipped by a lookup.
expect(cache.thumbFetched).toEqual([1, 2, 4]);
expect(cache.originalFetched).toEqual([]);
});
it("pins favorites then the latest-week window and precaches their originals in that order", async () => {
const cols = [collection(1), collection(2, "favorites")];
// File 10 is an old favorite (30 days old); files 1..3 are within the
// 7-day window; file 4 is outside it.
const files = [
file(1, 1, 0),
file(2, 1, 2),
file(3, 1, 6),
file(4, 1, 20),
file(10, 2, 30), // favorite, old
];
const cache = new FakeCache();
const pre = new Precache({ originalsDays: 7 });
pre.bind(cache);
pre.update(records(cols, files));
pre.start();
await until(() => cache.originalFetched.length === 4);
// Favorite (10) first, then the window newest-first (1, 2, 3). File 4
// is outside the window and never pinned.
expect(cache.originalFetched).toEqual([10, 1, 2, 3]);
expect(pre.isPinned(10)).toBe(true);
expect(pre.isPinned(1)).toBe(true);
expect(pre.isPinned(4)).toBe(false);
});
it("drops a file from the pinned set when the window moves past it", () => {
const cols = [collection(1)];
const cache = new FakeCache();
const pre = new Precache({ originalsDays: 7 });
pre.bind(cache);
pre.update(records(cols, [file(1, 1, 0), file(2, 1, 3)]));
expect(pre.isPinned(2)).toBe(true);
// A newer file arrives; the window's newest end moves forward so the
// 3-day-old file 2 (now 13 days behind the newest) falls out.
pre.update(
records(cols, [file(3, 1, -10), file(1, 1, 0), file(2, 1, 3)]),
);
expect(pre.isPinned(3)).toBe(true);
expect(pre.isPinned(2)).toBe(false);
});
it("reports progress through status()", async () => {
const cols = [collection(1), collection(2, "favorites")];
const files = [file(1, 1, 0), file(2, 1, 1), file(10, 2, 0)];
const cache = new FakeCache();
const pre = new Precache({ originalsDays: 7 });
pre.bind(cache);
pre.update(records(cols, files));
const before = pre.status();
expect(before.thumbnailsTotal).toBe(3);
expect(before.thumbnailsCached).toBe(0);
expect(before.originalsPinned).toBe(3); // files 1, 2, 10 all in window
expect(before.originalsCached).toBe(0);
pre.start();
await until(
() =>
cache.thumbFetched.length === 3 &&
cache.originalFetched.length === 3,
);
const after = pre.status();
expect(after.thumbnailsCached).toBe(3);
expect(after.originalsCached).toBe(3);
});
it("honours the disable flags", async () => {
const cols = [collection(1)];
const files = [file(1, 1, 0)];
const cache = new FakeCache();
const pre = new Precache({ thumbnails: false, originals: false });
pre.bind(cache);
pre.update(records(cols, files));
pre.start();
await new Promise((r) => setTimeout(r, 20));
expect(cache.thumbFetched).toEqual([]);
expect(cache.originalFetched).toEqual([]);
expect(pre.isPinned(1)).toBe(false);
expect(pre.status().thumbnailsTotal).toBe(0);
});
});
// ---- Integration through the real ContentCache and Library ----
const enteFile = (id: number, collectionID: number): EnteFile =>
file(id, collectionID, 0);
describe("Precache eviction integration", () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "quak-precache-evict-"));
});
afterEach(() => {
if (root && existsSync(root))
rmSync(root, { recursive: true, force: true });
});
it("never evicts a pinned original the precache put in place", async () => {
const cacheDir = join(root, "cache");
// File 1 is the favorites album's only file (pinned regardless of age);
// files 2 and 3 sit outside the latest-week window, so only file 1 is
// pinned. The by-id map serves the bytes for each fetch.
const cols = [collection(1), collection(2, "favorites")];
const files = [file(1, 2, 30), file(2, 1, 40), file(3, 1, 50)];
const byID = new Map<number, EnteFile>(files.map((f) => [f.id, f]));
const source: ContentSource = {
original: async ({ destination }) => {
await writeFile(destination, Buffer.alloc(10, 1));
return { bytesWritten: 10 };
},
thumbnail: async ({ destination }) => {
await writeFile(destination, Buffer.alloc(10, 1));
return { bytesWritten: 10 };
},
};
const statfs: StatFsFn = async () => ({
bsize: 1,
bavail: 1_000_000_000,
});
const pre = new Precache({ originalsDays: 7 });
const cache = new ContentCache({
pools: new RequestPools(),
source,
cacheDirectory: cacheDir,
getFile: (id) => byID.get(id),
statfs,
cacheOriginalsMaxBytes: 25, // holds two 10-byte originals
freeBelowBytes: 0,
isPinned: (id) => pre.isPinned(id),
});
pre.bind(cache);
pre.update(records(cols, files));
expect(pre.isPinned(1)).toBe(true);
expect(pre.isPinned(2)).toBe(false);
await cache.open();
// Fill three originals; the 25-byte cap forces an eviction on the
// third, and the pinned file 1 must survive it even though it is the
// least-recently-used.
await cache.original(1);
utimesSync(join(cacheDir, "originals", "1.jpg"), 1000, 1000); // oldest
await cache.original(2);
utimesSync(join(cacheDir, "originals", "2.jpg"), 2000, 2000);
await cache.original(3);
expect(existsSync(join(cacheDir, "originals", "1.jpg"))).toBe(true);
expect(existsSync(join(cacheDir, "originals", "2.jpg"))).toBe(false);
expect(existsSync(join(cacheDir, "originals", "3.jpg"))).toBe(true);
});
});
describe("Precache preemption", () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "quak-precache-preempt-"));
});
afterEach(() => {
if (root && existsSync(root))
rmSync(root, { recursive: true, force: true });
});
it("lets an on-demand original preempt the background originals fill", async () => {
const byID = new Map<number, EnteFile>([
[1, enteFile(1, 1)],
[2, enteFile(2, 1)],
[3, enteFile(3, 1)],
]);
const finished: number[] = [];
let openGate!: () => void;
const gate = new Promise<void>((r) => (openGate = r));
let sawFirst!: () => void;
const firstStarted = new Promise<void>((r) => (sawFirst = r));
let started = 0;
const source: ContentSource = {
original: async ({ file: f, destination }) => {
if (++started === 1) sawFirst();
await gate;
await writeFile(destination, Buffer.alloc(10, 1));
finished.push(f.id);
return { bytesWritten: 10 };
},
thumbnail: async ({ destination }) => {
await writeFile(destination, Buffer.alloc(10, 1));
return { bytesWritten: 10 };
},
};
// One content slot, so file 1 holds it while 2 and 3 wait.
const cache = new ContentCache({
pools: new RequestPools({ contentConcurrency: 1 }),
source,
cacheDirectory: join(root, "cache"),
getFile: (id) => byID.get(id),
statfs: async () => ({ bsize: 1, bavail: 1_000_000_000 }),
freeBelowBytes: 0,
});
await cache.open();
const pA = cache.ensureOriginals({ fileIDs: [1] }); // background
await firstStarted; // file 1 now holds the only slot
const pB = cache.original(2); // on-demand, queued behind file 1
const pC = cache.ensureOriginals({ fileIDs: [3] }); // background, queued
await new Promise((r) => setTimeout(r, 5)); // let both enqueue
openGate();
await Promise.all([pA, pB, pC]);
// On-demand file 2 was served before the background file 3.
expect(finished).toEqual([1, 2, 3]);
});
});
describe("Precache through Library.open", () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "quak-precache-lib-"));
});
afterEach(() => {
if (root && existsSync(root))
rmSync(root, { recursive: true, force: true });
});
class MockClient {
served = false;
whoami(): { email: string; userID: number } {
return { email: "u@example.com", userID: 7 };
}
async collectionsSince(): Promise<CollectionsPage> {
if (this.served) return { collections: [], deleted: [], cursor: 1 };
this.served = true;
return {
collections: [collection(1), collection(2, "favorites")],
deleted: [],
cursor: 1,
};
}
async filesSince(args: { collectionID: number }): Promise<FilesPage> {
const files =
args.collectionID === 1
? [enteFile(1, 1), enteFile(2, 1)]
: [enteFile(3, 2)];
return { files, deleted: [], cursor: 1 };
}
}
it("starts both precaches from open() and reports them in status()", async () => {
const source: ContentSource = {
original: async ({ destination }) => {
await writeFile(destination, Buffer.alloc(10, 1));
return { bytesWritten: 10 };
},
thumbnail: async ({ destination }) => {
await writeFile(destination, Buffer.alloc(10, 1));
return { bytesWritten: 10 };
},
};
// Each fill reports "done" once the cache has recorded its files. The
// source returning is not enough: the cache records a file only after
// it has checked it on disk.
const finished = new Set<string>();
let bothFinished!: () => void;
const precached = new Promise<void>((r) => (bothFinished = r));
const lib = await Library.open({
client: new MockClient(),
cacheDirectory: join(root, "cache"),
contentSource: source,
refreshIntervalSeconds: 3600,
onProgress: (e) => {
if (
e.status === "done" &&
(e.operation === "precacheThumbnails" ||
e.operation === "precacheOriginals")
) {
finished.add(e.operation);
if (finished.size === 2) bothFinished();
}
},
});
// Every file's thumbnail is precached; the favorite (file 3) and the
// week's files (1, 2) all have their originals precached.
await precached;
const status = lib.status();
expect(status.thumbnailsTotal).toBe(3);
expect(status.thumbnailsCached).toBe(3);
expect(status.originalsPinned).toBe(3);
expect(status.originalsCached).toBe(3);
await lib.close();
});
it("close() resolves only after a running precache fetch has written its file", async () => {
// Every thumbnail fetch waits until the test releases it.
let release!: () => void;
const held = new Promise<void>((r) => (release = r));
let fetchStarted!: (destination: string) => void;
const started = new Promise<string>((r) => (fetchStarted = r));
const source: ContentSource = {
original: async ({ destination }) => {
await writeFile(destination, Buffer.alloc(10, 1));
return { bytesWritten: 10 };
},
thumbnail: async ({ destination }) => {
fetchStarted(destination);
await held;
await writeFile(destination, Buffer.alloc(10, 1));
return { bytesWritten: 10 };
},
};
const lib = await Library.open({
client: new MockClient(),
cacheDirectory: join(root, "cache"),
contentSource: source,
refreshIntervalSeconds: 3600,
precacheOriginals: false,
});
try {
const destination = await started;
let closed = false;
const closing = lib.close().then(() => {
closed = true;
});
await new Promise((r) => setTimeout(r, 20));
expect(closed).toBe(false);
release();
await closing;
expect(existsSync(destination)).toBe(true);
} finally {
release();
await lib.close();
}
});
});
+537
View File
@@ -0,0 +1,537 @@
/**
* Tests for the in-process read surface (issue #44).
*
* Phase 1 (#43) projected the decrypted store into plain `AlbumRecord` /
* `PhotoRecord` values. This phase adds the read API a CLI or in-process script
* uses, all served from RAM with no network:
*
* - `lib.albums` `list` / `byName` / `byID`, returning thin `Album` wrappers.
* - `lib.photos` `byID` (a `Photo` wrapper) and `records` (plain records).
* - `lib.timeline.groups` photos bucketed by local day / week / month.
*
* Every call takes a single named-argument object; there are no positional
* arguments. The wrapper classes are for in-process callers only (they hold
* object identity, not JSON); the plain records remain the IPC-safe surface.
* Content-fetch methods (`Photo.original` / `thumbnail`) are a later unit and
* deliberately absent here this surface is read-only.
*
* The detailed cases drive the API factories directly over a hand-built
* projection (`deriveRecords`), which keeps them free of disk and timers. A
* final section opens a real `Library` to prove the namespaces are wired to the
* live store and that a read never touches the client.
*/
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
deriveRecords,
type DerivedRecords,
} from "../../src/library/records.js";
import {
Album,
Photo,
makeAlbumsAPI,
makePhotosAPI,
makeTimelineAPI,
type TimelineGroup,
} from "../../src/library/read.js";
import { Library } from "../../src/library/index.js";
import { MetadataStore } from "../../src/library/store.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
const OWNER = 42;
// Ente stores times in microseconds; records expose milliseconds. These
// helpers keep the fixtures readable: `ms(...)` picks an epoch-millisecond
// instant, `micros(...)` is what the fixture stores so the derived record's
// `takenAt` comes back as the same millisecond value.
const ms = (epochMillis: number): number => epochMillis;
const micros = (epochMillis: number): number => epochMillis * 1000;
const collection = (
id: number,
opts: Partial<Collection> = {},
): Collection => ({
id,
ownerID: OWNER,
key: new Uint8Array([id & 0xff, 1, 2, 3]),
name: `album-${id}`,
type: "album",
updationTime: micros(1_700_000_000_000),
isShared: false,
...opts,
});
const file = (
id: number,
collectionID: number,
opts: Partial<EnteFile> & {
creationTime?: number;
title?: string;
fileType?: EnteFile["metadata"]["fileType"];
latitude?: number;
longitude?: number;
} = {},
): EnteFile => {
const { creationTime, title, fileType, latitude, longitude, ...rest } =
opts;
const metadata: EnteFile["metadata"] = {
title: title ?? `file-${id}.jpg`,
fileType: fileType ?? "image",
creationTime: creationTime ?? micros(1_700_000_000_000),
modificationTime: micros(1_700_000_000_000),
};
if (latitude !== undefined) metadata.latitude = latitude;
if (longitude !== undefined) metadata.longitude = longitude;
return {
id,
collectionID,
ownerID: OWNER,
key: new Uint8Array([id & 0xff, 9, 8, 7]),
metadata,
file: { decryptionHeader: "aGVhZGVy" },
thumbnail: { decryptionHeader: "dGh1bWI=" },
updationTime: micros(1_700_000_000_000),
...rest,
};
};
// Build the three API objects over one fixed projection, the way `Library`
// wires them over its live store.
const apis = (records: DerivedRecords) => {
const derive = () => records;
return {
albums: makeAlbumsAPI(derive),
photos: makePhotosAPI(derive),
timeline: makeTimelineAPI(derive),
};
};
// Every file id present across all timeline groups, in group-then-member order.
const allFileIDs = (groups: TimelineGroup[]): number[] =>
groups.flatMap((g) => g.fileIDs);
describe("lib.albums", () => {
it("lists albums as Album wrappers, newest updated first", () => {
const records = deriveRecords(
[
collection(1, { updationTime: micros(1_700_000_000_000) }),
collection(2, { updationTime: micros(1_705_000_000_000) }),
],
[file(10, 1), file(20, 2)],
);
const albums = apis(records).albums.list();
expect(albums.every((a) => a instanceof Album)).toBe(true);
// Collection 2 updated later, so it sorts ahead of collection 1.
expect(albums.map((a) => a.collectionID)).toEqual([2, 1]);
});
it("exposes album fields and its photos newest first", () => {
const records = deriveRecords(
[
collection(7, {
name: "Trip",
type: "favorites",
isShared: true,
}),
],
[
file(1, 7, { creationTime: micros(1_600_000_000_000) }),
file(2, 7, { creationTime: micros(1_800_000_000_000) }),
file(3, 7, { creationTime: micros(1_700_000_000_000) }),
],
);
const album = apis(records).albums.byID({ collectionID: 7 })!;
expect(album.name).toBe("Trip");
expect(album.type).toBe("favorites");
expect(album.isShared).toBe(true);
expect(album.fileIDs).toEqual([2, 3, 1]);
const photos = album.photos.list();
expect(photos.every((p) => p instanceof Photo)).toBe(true);
expect(photos.map((p) => p.fileID)).toEqual([2, 3, 1]);
// record() hands back the plain, JSON-safe projection.
expect("key" in album.record()).toBe(false);
});
it("finds an album by exact name and returns undefined when absent", () => {
const records = deriveRecords(
[
collection(1, { name: "Berlin" }),
collection(2, { name: "Paris" }),
],
[file(10, 1), file(20, 2)],
);
const { albums } = apis(records);
expect(albums.byName({ albumName: "Paris" })?.collectionID).toBe(2);
expect(albums.byName({ albumName: "paris" })).toBeUndefined();
expect(albums.byName({ albumName: "Nowhere" })).toBeUndefined();
});
it("returns undefined for an unknown collection id", () => {
const records = deriveRecords([collection(1)], [file(10, 1)]);
expect(
apis(records).albums.byID({ collectionID: 999 }),
).toBeUndefined();
});
});
describe("lib.photos", () => {
it("byID returns a Photo wrapper carrying the mapped fields", () => {
const records = deriveRecords(
[collection(1)],
[
file(1001, 1, {
title: "IMG.jpg",
fileType: "video",
creationTime: micros(1_699_000_000_000),
latitude: 52.52,
longitude: 13.405,
pubMagicMetadata: { caption: "at the lake" },
}),
],
);
const photo = apis(records).photos.byID({ fileID: 1001 })!;
expect(photo).toBeInstanceOf(Photo);
expect(photo.title).toBe("IMG.jpg");
expect(photo.fileType).toBe("video");
expect(photo.takenAt).toBe(ms(1_699_000_000_000));
expect(photo.caption).toBe("at the lake");
expect(photo.latitude).toBeCloseTo(52.52);
expect(photo.isArchived).toBe(false);
expect(photo.isHidden).toBe(false);
// The wrapper hands back the plain record, IPC-safe.
expect("key" in photo.record()).toBe(false);
});
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();
});
it("records() returns plain records in requested order, deduped, skipping unknowns", () => {
const records = deriveRecords(
[collection(1)],
[file(1, 1), file(2, 1), file(3, 1)],
);
const out = apis(records).photos.records({
fileIDs: [3, 1, 3, 999, 2],
});
// Requested order preserved; the repeated 3 appears once; 999 is dropped.
expect(out.map((r) => r.fileID)).toEqual([3, 1, 2]);
// Plain records, not wrappers, and JSON round-trips whole.
expect(out[0]).not.toBeInstanceOf(Photo);
expect(JSON.parse(JSON.stringify(out[0]))).toEqual(out[0]);
});
it("emits one record for a file even when it belongs to several albums", () => {
// File 1001 is a member of collections 1 and 2.
const records = deriveRecords(
[collection(1), collection(2)],
[file(1001, 1), file(1001, 2)],
);
const out = apis(records).photos.records({ fileIDs: [1001, 1001] });
expect(out).toHaveLength(1);
expect(out[0]!.albumIDs).toEqual([1, 2]);
});
});
describe("lib.timeline grouping", () => {
// Group keys and `startsAt` are computed in local time. Pinning the zone to
// UTC makes the expected values exact and lets the fixtures use `Date.UTC`.
const savedTZ = process.env.TZ;
beforeAll(() => {
process.env.TZ = "UTC";
});
afterAll(() => {
if (savedTZ === undefined) delete process.env.TZ;
else process.env.TZ = savedTZ;
});
it("buckets by local day, newest group and newest member first", () => {
const records = deriveRecords(
[collection(1)],
[
file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 15, 9)) }),
file(2, 1, { creationTime: micros(Date.UTC(2024, 0, 15, 18)) }),
file(3, 1, { creationTime: micros(Date.UTC(2024, 0, 16, 12)) }),
file(4, 1, { creationTime: micros(Date.UTC(2024, 1, 1, 12)) }),
],
);
const groups = apis(records).timeline.groups({ groupBy: "day" });
expect(groups.map((g) => g.key)).toEqual([
"2024-02-01",
"2024-01-16",
"2024-01-15",
]);
// Group start is local midnight of the day.
expect(groups[2]!.startsAt).toBe(Date.UTC(2024, 0, 15));
// Within the 2024-01-15 group, the later photo (id 2) is first.
expect(groups[2]!.fileIDs).toEqual([2, 1]);
});
it("buckets by week with weeks starting on Monday", () => {
// 2024-01-15 is a Monday; the week runs through Sunday 2024-01-21.
const records = deriveRecords(
[collection(1)],
[
file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 15, 12)) }), // Mon
file(2, 1, { creationTime: micros(Date.UTC(2024, 0, 17, 12)) }), // Wed
file(3, 1, { creationTime: micros(Date.UTC(2024, 0, 21, 12)) }), // Sun
file(4, 1, { creationTime: micros(Date.UTC(2024, 0, 22, 12)) }), // next Mon
],
);
const groups = apis(records).timeline.groups({ groupBy: "week" });
// ISO week keys: 2024-01-15 is in 2024-W03, the next Monday in 2024-W04.
expect(groups.map((g) => g.key)).toEqual(["2024-W04", "2024-W03"]);
const first = groups.find((g) => g.key === "2024-W03")!;
expect(first.startsAt).toBe(Date.UTC(2024, 0, 15));
// The Sunday belongs to the Monday-started week, not the next one.
expect(first.fileIDs.sort((a, b) => a - b)).toEqual([1, 2, 3]);
});
it("assigns a Sunday to the preceding Monday's week across a month boundary", () => {
// 2024-01-14 is a Sunday; its week started Monday 2024-01-08.
const records = deriveRecords(
[collection(1)],
[file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 14, 12)) })],
);
const groups = apis(records).timeline.groups({ groupBy: "week" });
expect(groups.map((g) => g.key)).toEqual(["2024-W02"]);
expect(groups[0]!.startsAt).toBe(Date.UTC(2024, 0, 8));
});
it("buckets by month", () => {
const records = deriveRecords(
[collection(1)],
[
file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 3, 12)) }),
file(2, 1, { creationTime: micros(Date.UTC(2024, 0, 28, 12)) }),
file(3, 1, { creationTime: micros(Date.UTC(2024, 1, 9, 12)) }),
],
);
const groups = apis(records).timeline.groups({ groupBy: "month" });
expect(groups.map((g) => g.key)).toEqual(["2024-02", "2024-01"]);
expect(groups[1]!.startsAt).toBe(Date.UTC(2024, 0, 1));
expect(groups[1]!.fileIDs).toEqual([2, 1]);
});
it("lists each file once even when it belongs to several albums", () => {
// File 1001 is in collections 1 and 2 but must appear once in a group.
const records = deriveRecords(
[collection(1), collection(2)],
[
file(1001, 1, {
creationTime: micros(Date.UTC(2024, 0, 15, 12)),
}),
file(1001, 2, {
creationTime: micros(Date.UTC(2024, 0, 15, 12)),
}),
],
);
const groups = apis(records).timeline.groups({ groupBy: "day" });
expect(allFileIDs(groups)).toEqual([1001]);
});
});
describe("lib.timeline uses local time, not UTC", () => {
const savedTZ = process.env.TZ;
afterAll(() => {
if (savedTZ === undefined) delete process.env.TZ;
else process.env.TZ = savedTZ;
});
it("buckets by the viewer's local day", () => {
// Kolkata is UTC+5:30 with no DST. An instant at 2024-01-14T20:00Z is
// 2024-01-15 01:30 local, so it belongs to the local day 2024-01-15.
process.env.TZ = "Asia/Kolkata";
const records = deriveRecords(
[collection(1)],
[file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 14, 20)) })],
);
const groups = apis(records).timeline.groups({ groupBy: "day" });
expect(groups[0]!.key).toBe("2024-01-15");
// Local midnight of 2024-01-15, which is 2024-01-14T18:30Z.
expect(groups[0]!.startsAt).toBe(new Date(2024, 0, 15).getTime());
expect(groups[0]!.startsAt).toBe(Date.UTC(2024, 0, 14, 18, 30));
});
});
describe("PhotoFilter", () => {
// A fixture spanning albums, file types, geotags, captions, and the two
// visibility states, all on the same local day so grouping is incidental.
const day = (h: number): number => micros(Date.UTC(2024, 2, 4, h));
const records = (): DerivedRecords =>
deriveRecords(
[
collection(1, { name: "Holidays" }),
collection(2, { name: "Work" }),
],
[
file(1, 1, {
title: "Beach sunset",
creationTime: day(1),
latitude: 1,
longitude: 2,
}),
file(2, 1, {
title: "clip.mov",
fileType: "video",
creationTime: day(2),
}),
file(3, 2, {
title: "invoice scan",
creationTime: day(3),
pubMagicMetadata: { caption: "SUNSET colours" },
}),
file(4, 2, {
title: "archived note",
creationTime: day(4),
magicMetadata: { visibility: 1 },
}),
file(5, 2, {
title: "secret",
creationTime: day(5),
magicMetadata: { visibility: 2 },
}),
],
);
const idsWith = (
filter: Parameters<
ReturnType<typeof apis>["timeline"]["groups"]
>[0]["filter"],
): number[] =>
allFileIDs(
apis(records()).timeline.groups({ groupBy: "day", filter }),
).sort((a, b) => a - b);
it("never includes hidden photos and excludes archived by default", () => {
// No filter: hidden (5) always gone, archived (4) gone unless asked for.
expect(idsWith(undefined)).toEqual([1, 2, 3]);
});
it("includes archived photos when includeArchived is set, hidden still never", () => {
expect(idsWith({ includeArchived: true })).toEqual([1, 2, 3, 4]);
});
it("filters by album membership", () => {
expect(idsWith({ albumID: 1 })).toEqual([1, 2]);
expect(idsWith({ albumID: 2 })).toEqual([3]);
});
it("filters by file type", () => {
expect(idsWith({ fileTypes: ["video"] })).toEqual([2]);
expect(idsWith({ fileTypes: ["image", "video"] })).toEqual([1, 2, 3]);
});
it("filters by presence or absence of location", () => {
expect(idsWith({ hasLocation: true })).toEqual([1]);
expect(idsWith({ hasLocation: false })).toEqual([2, 3]);
});
it("matches text case-insensitively against title, caption, and album name", () => {
// Title match (case-insensitive): "Beach sunset".
expect(idsWith({ text: "SUNSET" })).toEqual([1, 3]);
// Caption-only match: file 3's caption is "SUNSET colours".
expect(idsWith({ text: "colours" })).toEqual([3]);
// Album-name match: everything in "Holidays".
expect(idsWith({ text: "holiday" })).toEqual([1, 2]);
});
it("combines filters", () => {
// Images in album 1 with a location: only file 1.
expect(
idsWith({ albumID: 1, fileTypes: ["image"], hasLocation: true }),
).toEqual([1]);
});
});
/**
* A minimal mock `Client`, enough for `Library.open` to run its refresh loop.
* The queues are empty, so the background refresh over a seeded cache changes
* nothing; the counters prove that a read never calls the client.
*/
class MockClient {
userID = OWNER;
collectionsCalls = 0;
filesCalls = 0;
whoami(): { email: string; userID: number } {
return { email: "user@example.com", userID: this.userID };
}
async collectionsSince(args: {
sinceTime: number;
}): Promise<CollectionsPage> {
this.collectionsCalls++;
return { collections: [], deleted: [], cursor: args.sinceTime };
}
async filesSince(args: {
collectionID: number;
collectionKey: Uint8Array;
sinceTime: number;
}): Promise<FilesPage> {
this.filesCalls++;
return { files: [], deleted: [], cursor: args.sinceTime };
}
}
describe("Library exposes the read surface over its live store", () => {
let dir: string;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), "quak-read-"));
});
afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});
it("serves albums, photos, and timeline from RAM without calling the client", async () => {
const cacheDirectory = join(dir, "cache");
const path = join(cacheDirectory, "metadata.json");
// Seed a cache as a prior run left it, so open() serves it at once.
const seed = await MetadataStore.load(path);
seed.userID = OWNER;
seed.collectionsSinceTime = 100;
seed.putCollection(collection(1, { name: "Seeded" }));
seed.putFile(
file(1001, 1, { creationTime: micros(Date.UTC(2024, 5, 1, 12)) }),
);
await seed.save();
const client = new MockClient();
// A long interval keeps the background timer from firing during the test.
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: 3600,
});
try {
const collectionsBefore = client.collectionsCalls;
const filesBefore = client.filesCalls;
expect(lib.albums.list().map((a) => a.name)).toEqual(["Seeded"]);
expect(
lib.albums.byName({ albumName: "Seeded" })?.collectionID,
).toBe(1);
expect(lib.photos.byID({ fileID: 1001 })?.fileID).toBe(1001);
expect(
lib.photos.records({ fileIDs: [1001] }).map((r) => r.fileID),
).toEqual([1001]);
const groups = lib.timeline.groups({ groupBy: "month" });
expect(allFileIDs(groups)).toEqual([1001]);
// Reads are answered from RAM: no read called the client.
expect(client.collectionsCalls).toBe(collectionsBefore);
expect(client.filesCalls).toBe(filesBefore);
} finally {
await lib.close();
}
});
});
+279
View File
@@ -0,0 +1,279 @@
/**
* Tests for the plain-record mapping in `src/library/records.ts` (issue #43).
*
* The library keeps decrypted `Collection`/`EnteFile` objects in RAM, but those
* carry binary keys and cannot cross the Electron IPC boundary. `deriveRecords`
* projects them into plain `AlbumRecord`/`PhotoRecord` values no keys, no
* `Uint8Array`, JSON-safe that the GUI process consumes. This file pins:
*
* 1. Field mapping from `metadata` and the two magic-metadata layers, using the
* real Ente field names confirmed against the fixtures in
* `test/cli/metadata-backup.test.ts` (`w`/`h`) and `test/library/store.test.ts`
* (`visibility`): title/takenAt precedence, caption, width/height, geo,
* visibility isArchived/isHidden, fileType.
* 2. `takenAt` is milliseconds; Ente stores creationTime/editedTime in
* microseconds, so the record divides by 1000.
* 3. Deduplication: one `PhotoRecord` per fileID even when the file belongs to
* several collections, with every membership's collection id in `albumIDs`.
* 4. Ordering: photos and album `fileIDs` are newest first.
* 5. No key material survives the projection.
* 6. `diffRecords` reports exactly what changed between two derivations, and
* returns undefined when nothing changed.
*/
import { describe, it, expect } from "vitest";
import {
deriveRecords,
snapshotFrom,
diffRecords,
} from "../../src/library/records.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
const OWNER = 42;
// Microsecond epoch values, as Ente stores times. 1e15 ≈ 2001 in microseconds.
const T = (micros: number): number => micros;
const collection = (
id: number,
opts: Partial<Collection> = {},
): Collection => ({
id,
ownerID: OWNER,
key: new Uint8Array([id & 0xff, 1, 2, 3]),
name: `album-${id}`,
type: "album",
updationTime: T(1_700_000_000_000_000),
isShared: false,
...opts,
});
const file = (
id: number,
collectionID: number,
opts: Partial<EnteFile> & {
creationTime?: number;
title?: string;
} = {},
): EnteFile => {
const { creationTime, title, ...rest } = opts;
return {
id,
collectionID,
ownerID: OWNER,
key: new Uint8Array([id & 0xff, 9, 8, 7]),
metadata: {
title: title ?? `file-${id}.jpg`,
fileType: "image",
creationTime: creationTime ?? T(1_700_000_000_000_000),
modificationTime: T(1_700_000_000_000_000),
},
file: { decryptionHeader: "aGVhZGVy" },
thumbnail: { decryptionHeader: "dGh1bWI=" },
updationTime: T(1_700_000_000_000_000),
...rest,
};
};
describe("deriveRecords: photo mapping", () => {
it("projects a file into a plain PhotoRecord with no key material", () => {
const f = file(1001, 1, {
metadata: {
title: "IMG_1.jpg",
fileType: "image",
creationTime: T(1_699_000_000_000_000),
modificationTime: T(1_699_000_000_000_000),
latitude: 52.52,
longitude: 13.405,
},
});
const { photos } = deriveRecords([collection(1)], [f]);
const rec = photos.get(1001)!;
expect(rec.fileID).toBe(1001);
expect(rec.albumIDs).toEqual([1]);
expect(rec.title).toBe("IMG_1.jpg");
expect(rec.fileType).toBe("image");
expect(rec.latitude).toBeCloseTo(52.52);
expect(rec.longitude).toBeCloseTo(13.405);
expect(rec.isArchived).toBe(false);
expect(rec.isHidden).toBe(false);
// Safe to send over IPC: no key, no Uint8Array, JSON round-trips whole.
expect("key" in rec).toBe(false);
expect(JSON.parse(JSON.stringify(rec))).toEqual(rec);
});
it("takenAt is creationTime converted from microseconds to milliseconds", () => {
const f = file(1001, 1, { creationTime: T(1_699_000_000_000_000) });
const { photos } = deriveRecords([collection(1)], [f]);
expect(photos.get(1001)!.takenAt).toBe(1_699_000_000_000);
});
it("prefers pubMagicMetadata.editedName and editedTime over metadata", () => {
const f = file(1001, 1, {
title: "original.jpg",
creationTime: T(1_699_000_000_000_000),
pubMagicMetadata: {
editedName: "Sunset over the bay",
editedTime: T(1_650_000_000_000_000),
},
});
const { photos } = deriveRecords([collection(1)], [f]);
const rec = photos.get(1001)!;
expect(rec.title).toBe("Sunset over the bay");
expect(rec.takenAt).toBe(1_650_000_000_000);
});
it("falls back to metadata when the edited fields are empty or absent", () => {
const f = file(1001, 1, {
title: "original.jpg",
creationTime: T(1_699_000_000_000_000),
pubMagicMetadata: { editedName: "" },
});
const { photos } = deriveRecords([collection(1)], [f]);
const rec = photos.get(1001)!;
expect(rec.title).toBe("original.jpg");
expect(rec.takenAt).toBe(1_699_000_000_000);
});
it("maps caption and width/height from the public magic metadata", () => {
const f = file(1001, 1, {
pubMagicMetadata: {
caption: "at the beach",
w: 3000,
h: 2000,
},
});
const rec = deriveRecords([collection(1)], [f]).photos.get(1001)!;
expect(rec.caption).toBe("at the beach");
expect(rec.width).toBe(3000);
expect(rec.height).toBe(2000);
});
it("omits optional fields that are absent from the metadata", () => {
const rec = deriveRecords([collection(1)], [file(1001, 1)]).photos.get(
1001,
)!;
expect("caption" in rec).toBe(false);
expect("width" in rec).toBe(false);
expect("height" in rec).toBe(false);
expect("latitude" in rec).toBe(false);
});
it("reads archived and hidden from private magicMetadata.visibility", () => {
const archived = file(1, 1, { magicMetadata: { visibility: 1 } });
const hidden = file(2, 1, { magicMetadata: { visibility: 2 } });
const visible = file(3, 1, { magicMetadata: { visibility: 0 } });
const { photos } = deriveRecords(
[collection(1)],
[archived, hidden, visible],
);
expect(photos.get(1)).toMatchObject({
isArchived: true,
isHidden: false,
});
expect(photos.get(2)).toMatchObject({
isArchived: false,
isHidden: true,
});
expect(photos.get(3)).toMatchObject({
isArchived: false,
isHidden: false,
});
});
});
describe("deriveRecords: dedup and ordering", () => {
it("emits one PhotoRecord per fileID across memberships, all albums listed", () => {
// File 1001 belongs to collections 1 and 2; 2002 only to 2.
const files = [
file(1001, 1, { creationTime: T(1_700_000_000_000_000) }),
file(1001, 2, { creationTime: T(1_700_000_000_000_000) }),
file(2002, 2, { creationTime: T(1_710_000_000_000_000) }),
];
const { photos } = deriveRecords([collection(1), collection(2)], files);
expect([...photos.keys()].sort((a, b) => a - b)).toEqual([1001, 2002]);
expect(photos.get(1001)!.albumIDs).toEqual([1, 2]);
expect(photos.get(2002)!.albumIDs).toEqual([2]);
});
it("orders snapshot photos newest first by takenAt", () => {
const files = [
file(1, 1, { creationTime: T(1_600_000_000_000_000) }),
file(2, 1, { creationTime: T(1_800_000_000_000_000) }),
file(3, 1, { creationTime: T(1_700_000_000_000_000) }),
];
const snap = snapshotFrom(deriveRecords([collection(1)], files), 123);
expect(snap.photos.map((p) => p.fileID)).toEqual([2, 3, 1]);
expect(snap.takenAt).toBe(123);
});
it("orders album fileIDs newest first", () => {
const files = [
file(1, 7, { creationTime: T(1_600_000_000_000_000) }),
file(2, 7, { creationTime: T(1_800_000_000_000_000) }),
file(3, 7, { creationTime: T(1_700_000_000_000_000) }),
];
const { albums } = deriveRecords([collection(7)], files);
expect(albums.get(7)!.fileIDs).toEqual([2, 3, 1]);
});
});
describe("deriveRecords: album mapping", () => {
it("carries collection identity, sharing, and the favorites type", () => {
const fav = collection(9, {
name: "Favorites",
type: "favorites",
isShared: true,
updationTime: T(1_705_000_000_000_000),
});
const rec = deriveRecords([fav], [file(1, 9)]).albums.get(9)!;
expect(rec).toMatchObject({
collectionID: 9,
name: "Favorites",
type: "favorites",
isShared: true,
updationTime: T(1_705_000_000_000_000),
});
expect("key" in rec).toBe(false);
expect(JSON.parse(JSON.stringify(rec))).toEqual(rec);
});
});
describe("diffRecords", () => {
const at = 999;
it("returns undefined when nothing changed", () => {
const a = deriveRecords([collection(1)], [file(1, 1)]);
const b = deriveRecords([collection(1)], [file(1, 1)]);
expect(diffRecords(a, b, at)).toBeUndefined();
});
it("reports added and changed albums and photos and removals", () => {
const before = deriveRecords(
[collection(1), collection(2)],
[file(1, 1), file(2, 2)],
);
// Collection 2 is gone (album + its only file removed). Collection 1 is
// renamed (changed album), gains file 3, and file 1 is retitled.
const after = deriveRecords(
[collection(1, { name: "renamed" })],
[
file(1, 1, {
pubMagicMetadata: { editedName: "new title" },
}),
file(3, 1),
],
);
const change = diffRecords(before, after, at)!;
expect(change.refreshedAt).toBe(at);
expect(change.albumIDsRemoved).toEqual([2]);
expect(change.fileIDsRemoved).toEqual([2]);
expect(change.albumsChanged.map((a) => a.collectionID)).toEqual([1]);
expect(change.albumsChanged[0]!.name).toBe("renamed");
expect(
change.photosChanged.map((p) => p.fileID).sort((x, y) => x - y),
).toEqual([1, 3]);
});
});
+303
View File
@@ -0,0 +1,303 @@
/**
* Tests for `Library.snapshot()` and `Library.subscribe()` (issue #43).
*
* These are the surface the GUI consumes across Electron IPC. `snapshot()` is
* synchronous it reads the in-RAM store and projects it into plain records
* (no keys) and `subscribe({ onChange })` delivers a `LibraryChange` whenever
* a background refresh actually changes the derived records. The contracts:
*
* 1. `snapshot()` deduplicates a file across memberships into one record with
* every album id, orders photos newest first, and carries no key material.
* 2. `subscribe` fires on a refresh that changes something, with the exact
* changed and removed sets for both albums and photos.
* 3. A refresh that changes nothing (an empty diff) fires no change.
* 4. `unsubscribe()` stops further delivery.
*
* The client is the same scripted mock used by the refresh-loop tests: no
* crypto, no network. Interval tests use a short real interval and `vi.waitFor`.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Library } from "../../src/library/index.js";
import type { LibraryChange } from "../../src/library/records.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
const USER_ID = 42;
const FAST_INTERVAL = 0.02;
const collection = (
id: number,
updationTime: number,
name = `album-${id}`,
): Collection => ({
id,
ownerID: USER_ID,
key: new Uint8Array([id & 0xff]),
name,
type: "album",
updationTime,
isShared: false,
});
const file = (
id: number,
collectionID: number,
creationTime: number,
): EnteFile => ({
id,
collectionID,
ownerID: USER_ID,
key: new Uint8Array([id & 0xff]),
metadata: {
title: `file-${id}.jpg`,
fileType: "image",
creationTime,
modificationTime: creationTime,
},
file: { decryptionHeader: "aGVhZGVy" },
thumbnail: { decryptionHeader: "dGh1bWI=" },
updationTime: creationTime,
});
class MockClient {
userID = USER_ID;
collectionsQueue: CollectionsPage[] = [];
filesByCollection = new Map<number, FilesPage[]>();
whoami(): { email: string; userID: number } {
return { email: "user@example.com", userID: this.userID };
}
async collectionsSince(args: {
sinceTime: number;
}): Promise<CollectionsPage> {
return (
this.collectionsQueue.shift() ?? {
collections: [],
deleted: [],
cursor: args.sinceTime,
}
);
}
async filesSince(args: {
collectionID: number;
collectionKey: Uint8Array;
sinceTime: number;
}): Promise<FilesPage> {
const queue = this.filesByCollection.get(args.collectionID);
return (
queue?.shift() ?? {
files: [],
deleted: [],
cursor: args.sinceTime,
}
);
}
filesFor(collectionID: number, ...pages: FilesPage[]): void {
this.filesByCollection.set(collectionID, pages);
}
}
describe("Library.snapshot and Library.subscribe", () => {
let dir: string;
let cacheDirectory: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "quak-snapshot-"));
cacheDirectory = join(dir, "cache");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("snapshot() dedupes across memberships, orders newest first, holds no keys", async () => {
const client = new MockClient();
client.collectionsQueue.push({
collections: [collection(1, 100), collection(2, 100)],
deleted: [],
cursor: 100,
});
// File 1001 is in both collections; 2002 only in collection 2 and newer.
client.filesFor(1, {
files: [file(1001, 1, 1_600_000_000_000_000)],
deleted: [],
cursor: 1_600_000_000_000_000,
});
client.filesFor(2, {
files: [
file(1001, 2, 1_600_000_000_000_000),
file(2002, 2, 1_800_000_000_000_000),
],
deleted: [],
cursor: 1_800_000_000_000_000,
});
const lib = await Library.open({ client, cacheDirectory });
try {
const snap = lib.snapshot();
// One record per fileID, newest first, both albums on the shared file.
expect(snap.photos.map((p) => p.fileID)).toEqual([2002, 1001]);
const shared = snap.photos.find((p) => p.fileID === 1001)!;
expect(shared.albumIDs).toEqual([1, 2]);
expect(shared.takenAt).toBe(1_600_000_000_000);
expect(snap.albums.map((a) => a.collectionID).sort()).toEqual([
1, 2,
]);
// Nothing carries key material; the whole snapshot is JSON-safe.
expect(JSON.parse(JSON.stringify(snap))).toEqual(snap);
for (const p of snap.photos) expect("key" in p).toBe(false);
for (const a of snap.albums) expect("key" in a).toBe(false);
} finally {
await lib.close();
}
});
it("subscribe fires on a refresh change with the correct changed/removed sets", async () => {
const client = new MockClient();
client.collectionsQueue.push({
collections: [collection(1, 100), collection(2, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(1, {
files: [file(1001, 1, 1_600_000_000_000_000)],
deleted: [],
cursor: 1_600_000_000_000_000,
});
client.filesFor(2, {
files: [file(2002, 2, 1_600_000_000_000_000)],
deleted: [],
cursor: 1_600_000_000_000_000,
});
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: FAST_INTERVAL,
});
const changes: LibraryChange[] = [];
const { unsubscribe } = lib.subscribe({
onChange: (c) => changes.push(c),
});
try {
// Next refresh: collection 2 (and its file) tombstoned; collection 1
// gains file 1003.
client.filesFor(1, {
files: [file(1003, 1, 1_700_000_000_000_000)],
deleted: [],
cursor: 1_700_000_000_000_000,
});
client.collectionsQueue.push({
collections: [collection(1, 200)],
deleted: [2],
cursor: 200,
});
await vi.waitFor(() => expect(changes.length).toBeGreaterThan(0), {
timeout: 2000,
interval: 5,
});
const change = changes[0]!;
expect(change.albumIDsRemoved).toEqual([2]);
expect(change.fileIDsRemoved).toEqual([2002]);
expect(change.photosChanged.map((p) => p.fileID)).toEqual([1003]);
expect(change.albumsChanged.map((a) => a.collectionID)).toEqual([
1,
]);
expect(change.refreshedAt).toBeGreaterThan(0);
} finally {
unsubscribe();
await lib.close();
}
});
it("a refresh that changes nothing fires no change", async () => {
const client = new MockClient();
client.collectionsQueue.push({
collections: [collection(1, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(1, {
files: [file(1001, 1, 1_600_000_000_000_000)],
deleted: [],
cursor: 1_600_000_000_000_000,
});
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: FAST_INTERVAL,
});
const changes: LibraryChange[] = [];
const { unsubscribe } = lib.subscribe({
onChange: (c) => changes.push(c),
});
try {
// Let several empty-diff ticks pass; none may deliver a change.
await new Promise((r) => setTimeout(r, FAST_INTERVAL * 1000 * 6));
expect(changes).toEqual([]);
} finally {
unsubscribe();
await lib.close();
}
});
it("unsubscribe stops further delivery", async () => {
const client = new MockClient();
client.collectionsQueue.push({
collections: [collection(1, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(1, {
files: [file(1001, 1, 1_600_000_000_000_000)],
deleted: [],
cursor: 1_600_000_000_000_000,
});
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: FAST_INTERVAL,
});
const changes: LibraryChange[] = [];
const { unsubscribe } = lib.subscribe({
onChange: (c) => changes.push(c),
});
unsubscribe();
try {
client.collectionsQueue.push({
collections: [collection(3, 300)],
deleted: [],
cursor: 300,
});
client.filesFor(3, {
files: [file(3003, 3, 1_700_000_000_000_000)],
deleted: [],
cursor: 1_700_000_000_000_000,
});
// The change lands in the store, but the cancelled subscriber sees
// nothing.
await vi.waitFor(
() => expect(lib.snapshot().albums.length).toBe(2),
{ timeout: 2000, interval: 5 },
);
expect(changes).toEqual([]);
} finally {
await lib.close();
}
});
});
+246
View File
@@ -0,0 +1,246 @@
/**
* Tests for the on-disk JSON metadata store (`MetadataStore`).
*
* The store is the local cache the library keeps of the account's server
* state: one `metadata.json` file holding 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. A separate refresh unit (issue #42) is what
* populates it; this unit only stores.
*
* Four contracts are load-bearing and each is exercised below:
*
* 1. **Round-trip fidelity.** Everything put into the store including the
* binary decryption keys, which JSON cannot hold directly and which the
* store base64-encodes comes back byte-for-byte after a save and a fresh
* load. A cache that quietly dropped or mangled a field would hand the
* caller wrong keys or stale metadata.
*
* 2. **A missing or corrupt file loads as an empty store, never an error.**
* The file is only a cache: if it is absent (first run) or unreadable
* (interrupted write on an older build, disk corruption, hand-editing),
* the right answer is to start empty and let the refresh unit repopulate,
* not to crash the whole library.
*
* 3. **Writes are atomic and durable.** The store reuses the same
* fsync-before-rename atomic writer the download layer uses, so a reader
* never sees a half-written file and a crash cannot leave a truncated one.
* The observable consequence tested here is that a save leaves exactly the
* destination file behind no temporary sibling and that overwriting an
* existing store preserves a complete, re-loadable file.
*
* 4. **Permissions match `session.json`.** The directory is `0700` and the
* file is `0600`, because the records contain decrypted key material and
* must not be readable by other users on a shared machine.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
mkdtempSync,
rmSync,
readdirSync,
statSync,
writeFileSync,
mkdirSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
MetadataStore,
METADATA_SCHEMA_VERSION,
} from "../../src/library/store.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
// A representative decrypted collection, including a binary key and all three
// magic-metadata layers, so the round-trip test proves every field survives.
const sampleCollection = (): Collection => ({
id: 12345,
ownerID: 42,
key: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]),
name: "Holiday 2026",
type: "album",
updationTime: 1_700_000_000_000_000,
isShared: true,
magicMetadata: { visibility: 0 },
pubMagicMetadata: { subType: 0, coverID: 999 },
sharedMagicMetadata: { note: "shared with a friend" },
});
// A representative decrypted file membership: metadata, both blob headers, a
// binary key, a content hash, and file/thumbnail sizes.
const sampleFile = (): EnteFile => ({
id: 67890,
collectionID: 12345,
ownerID: 42,
key: new Uint8Array([9, 8, 7, 6, 5, 4, 3, 2, 1]),
metadata: {
title: "IMG_0001.jpg",
fileType: "image",
creationTime: 1_699_000_000_000_000,
modificationTime: 1_699_000_500_000_000,
latitude: 52.52,
longitude: 13.405,
hash: "sha256:deadbeef",
},
magicMetadata: { editedName: "sunset" },
pubMagicMetadata: { editedTime: 1_699_000_600_000_000 },
file: { decryptionHeader: "ZmlsZUhlYWRlcg==", size: 4_194_304 },
thumbnail: { decryptionHeader: "dGh1bWJIZWFkZXI=", size: 8192 },
updationTime: 1_700_000_100_000_000,
});
describe("MetadataStore", () => {
let dir: string;
let path: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "quak-store-"));
// Deliberately nest the store one level below the temp dir so save()
// has to create its own directory and set its mode.
path = join(dir, "cache", "metadata.json");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("round-trips the whole model, keys and all", async () => {
const store = await MetadataStore.load(path);
store.userID = 42;
store.collectionsSinceTime = 1_700_000_000_000_000;
store.putCollection(sampleCollection());
store.putFile(sampleFile());
await store.save();
const reloaded = await MetadataStore.load(path);
expect(reloaded.userID).toBe(42);
expect(reloaded.collectionsSinceTime).toBe(1_700_000_000_000_000);
// The binary key must come back as the exact bytes, not a base64
// string or a plain object of numbered keys.
const collection = reloaded.getCollection(12345);
expect(collection).toEqual(sampleCollection());
expect(collection?.key).toBeInstanceOf(Uint8Array);
const file = reloaded.getFile(12345, 67890);
expect(file).toEqual(sampleFile());
expect(file?.key).toBeInstanceOf(Uint8Array);
expect(reloaded.listCollections()).toHaveLength(1);
expect(reloaded.listFiles(12345)).toHaveLength(1);
});
it("writes the declared schema version", async () => {
const store = await MetadataStore.load(path);
await store.save();
const reloaded = await MetadataStore.load(path);
expect(reloaded.schemaVersion).toBe(METADATA_SCHEMA_VERSION);
});
it("loads an empty store when the file is missing", async () => {
const store = await MetadataStore.load(path);
expect(store.userID).toBe(0);
expect(store.listCollections()).toEqual([]);
expect(store.getCollection(1)).toBeUndefined();
});
it("loads an empty store when the file is corrupt", async () => {
mkdirSync(join(dir, "cache"), { recursive: true });
writeFileSync(path, "{ this is not valid json ][");
const store = await MetadataStore.load(path);
expect(store.listCollections()).toEqual([]);
expect(store.listFiles(12345)).toEqual([]);
});
it("loads an empty store when the schema version does not match", async () => {
// A cache written by a future build with an incompatible schema is
// discarded rather than misread; the refresh unit repopulates it.
mkdirSync(join(dir, "cache"), { recursive: true });
writeFileSync(
path,
JSON.stringify({
schemaVersion: METADATA_SCHEMA_VERSION + 1,
userID: 42,
collectionsSinceTime: 0,
collections: [],
files: [],
}),
);
const store = await MetadataStore.load(path);
expect(store.userID).toBe(0);
expect(store.listCollections()).toEqual([]);
});
it("creates the directory 0700 and the file 0600", async () => {
const store = await MetadataStore.load(path);
store.putCollection(sampleCollection());
await store.save();
// Directory 0700, file 0600: on a shared machine the decrypted keys
// in this file must be readable only by their owner. Mask to the
// permission bits; the file-type bits are not part of the assertion.
expect(statSync(join(dir, "cache")).mode & 0o777).toBe(0o700);
expect(statSync(path).mode & 0o777).toBe(0o600);
});
it("leaves exactly the destination behind, with no temp sibling", async () => {
const store = await MetadataStore.load(path);
store.putCollection(sampleCollection());
await store.save();
// The atomic writer stages a temporary file and renames it into
// place; on success nothing temporary is left in the directory.
expect(readdirSync(join(dir, "cache"))).toEqual(["metadata.json"]);
});
it("overwrites an existing store atomically and stays re-loadable", async () => {
const first = await MetadataStore.load(path);
first.userID = 1;
first.putCollection(sampleCollection());
await first.save();
const second = await MetadataStore.load(path);
second.userID = 2;
second.deleteCollection(12345);
await second.save();
const reloaded = await MetadataStore.load(path);
expect(reloaded.userID).toBe(2);
expect(reloaded.getCollection(12345)).toBeUndefined();
expect(readdirSync(join(dir, "cache"))).toEqual(["metadata.json"]);
});
it("deletes a collection together with its file memberships", async () => {
const store = await MetadataStore.load(path);
store.putCollection(sampleCollection());
store.putFile(sampleFile());
store.deleteCollection(12345);
expect(store.getCollection(12345)).toBeUndefined();
expect(store.getFile(12345, 67890)).toBeUndefined();
expect(store.listFiles(12345)).toEqual([]);
});
it("scopes file records to their collection membership", async () => {
// The same underlying file can be a member of two collections, each a
// separate record with its own key. Storing one must not touch the
// other, and lookups are per membership.
const store = await MetadataStore.load(path);
const inA = sampleFile();
const inB: EnteFile = {
...sampleFile(),
collectionID: 55555,
key: new Uint8Array([100, 101, 102]),
};
store.putFile(inA);
store.putFile(inB);
expect(store.getFile(12345, 67890)?.key).toEqual(inA.key);
expect(store.getFile(55555, 67890)?.key).toEqual(inB.key);
expect(store.listFiles(12345)).toHaveLength(1);
expect(store.listFiles(55555)).toHaveLength(1);
store.deleteFile(12345, 67890);
expect(store.getFile(12345, 67890)).toBeUndefined();
expect(store.getFile(55555, 67890)?.key).toEqual(inB.key);
});
});
+82 -3
View File
@@ -145,7 +145,15 @@ const buildSharedRawCollection = (
const buildRawFile = (
collectionKey: Uint8Array,
opts?: { title?: string; fileType?: number; creationTime?: number },
opts?: {
// Any JSON value; `undefined` leaves the title out of the metadata.
title?: unknown;
fileType?: number;
creationTime?: number;
info?: { fileSize?: number; thumbSize?: number };
// Replaces the whole metadata JSON value.
metadata?: unknown;
},
): RawEnteFile => {
const fileKey = sodium.crypto_secretbox_keygen();
const { ciphertext: encFileKey, nonce: fileKeyNonce } = secretboxEncrypt(
@@ -153,8 +161,8 @@ const buildRawFile = (
collectionKey,
);
const metadata = {
title: opts?.title ?? "IMG_0001.jpg",
const defaultMetadata = {
title: opts && "title" in opts ? opts.title : "IMG_0001.jpg",
fileType: opts?.fileType ?? 0,
creationTime: opts?.creationTime ?? 1700000000000000,
modificationTime: 1700000000000000,
@@ -162,6 +170,8 @@ const buildRawFile = (
longitude: 2.3522,
hash: "abcdef1234567890",
};
const metadata =
opts && "metadata" in opts ? opts.metadata : defaultMetadata;
// File metadata is encrypted as a single-chunk secretstream blob
// (not secretbox). The decryptionHeader is the secretstream init header.
const metadataBytes = new TextEncoder().encode(JSON.stringify(metadata));
@@ -186,6 +196,7 @@ const buildRawFile = (
},
file: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
thumbnail: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
info: opts?.info,
updationTime: 1700000000000000,
};
};
@@ -315,6 +326,31 @@ describe("model.decryptFile", () => {
expect(file.metadata.longitude).toBeCloseTo(2.3522);
});
it("reads a missing or non-string title as an empty string", () => {
// The server controls the metadata JSON. A title that is not a
// string must not reach code that builds file names from it.
const masterKey = sodium.crypto_secretbox_keygen();
const { collectionKey } = buildRawCollection(masterKey);
for (const title of [undefined, null, 42, ["a"], { x: "../y" }]) {
const file = decryptFile(
buildRawFile(collectionKey, { title }),
collectionKey,
);
expect(file.metadata.title).toBe("");
}
});
it("rejects metadata that is not a JSON object", () => {
const masterKey = sodium.crypto_secretbox_keygen();
const { collectionKey } = buildRawCollection(masterKey);
for (const metadata of [null, "IMG_0001.jpg", 7, []]) {
const raw = buildRawFile(collectionKey, { metadata });
expect(() => decryptFile(raw, collectionKey)).toThrow(
"file 200: metadata is not a JSON object",
);
}
});
it("maps fileType numbers to FileType strings", () => {
// Ente uses: 0=image, 1=video, 2=livePhoto
const masterKey = sodium.crypto_secretbox_keygen();
@@ -357,4 +393,47 @@ describe("model.decryptFile", () => {
expect(() => decryptFile(raw, wrongKey)).toThrow();
});
it("carries the file and thumbnail byte sizes from info", () => {
// The server reports the encrypted-blob sizes in `info`; the cache
// needs them without a HEAD request, so decryptFile must copy them
// onto the file and thumbnail blobs.
const masterKey = sodium.crypto_secretbox_keygen();
const { collectionKey } = buildRawCollection(masterKey);
const raw = buildRawFile(collectionKey, {
info: { fileSize: 4096, thumbSize: 512 },
});
const file = decryptFile(raw, collectionKey);
expect(file.file.size).toBe(4096);
expect(file.thumbnail.size).toBe(512);
});
it("leaves the sizes undefined when the server omits info", () => {
// Older files predate the info field; the sizes must stay undefined
// rather than become 0, so callers can tell "unknown" from "empty".
const masterKey = sodium.crypto_secretbox_keygen();
const { collectionKey } = buildRawCollection(masterKey);
const raw = buildRawFile(collectionKey);
expect(raw.info).toBeUndefined();
const file = decryptFile(raw, collectionKey);
expect(file.file.size).toBeUndefined();
expect(file.thumbnail.size).toBeUndefined();
});
it("carries the deletion flag from the diff row", () => {
// The diff marks a deleted row with isDeleted; decryptFile copies it
// onto the file so a caller can tell a deleted row from a live one.
const masterKey = sodium.crypto_secretbox_keygen();
const { collectionKey } = buildRawCollection(masterKey);
const raw = buildRawFile(collectionKey);
raw.isDeleted = true;
const file = decryptFile(raw, collectionKey);
expect(file.isDeleted).toBe(true);
});
});
+16 -1
View File
@@ -12,7 +12,7 @@
//
// Neither shows up as a build failure, so they are asserted here.
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { join } from "node:path";
@@ -46,4 +46,19 @@ describe(".dockerignore", () => {
it("leaves .gitignore in the build context for prettier", () => {
expect(dockerignore).not.toContain(".gitignore");
});
// Both images are built from this same context, and the lint image runs
// eslint and prettier across it. BuildKit lets a `<dockerfile>.dockerignore`
// shadow the root one for a single build; such a file would silently give
// the lint build a different, unreviewed context — and eslint's flat config
// does not ignore dot-directories, so a stray `.claude/` worktree would be
// linted.
it.each(["Dockerfile", "Dockerfile.lint"])(
"is not shadowed by a per-Dockerfile ignore file for %s",
(name) => {
expect(existsSync(join(repoRoot, `${name}.dockerignore`))).toBe(
false,
);
},
);
});
+4 -2
View File
@@ -1,7 +1,9 @@
// The package manifest promises three files that only exist after a build:
// `main`, `types`, and the `quak` binary. Nothing in the test suite used to
// look at them, and `make check` runs test, lint and fmt-check but never the
// build, so `tsconfig.json` and `package.json` were free to drift apart. They
// look at them, and `make check` runs the suite and the lint container but
// never the build, so `tsconfig.json` and `package.json` were free to drift
// apart. (The formatting check is part of the lint container, not a step of
// its own; `test/packaging/lint-once.test.ts` is what holds that shape.) They
// did: `rootDir` was `./src` while `include` also pulled in `bin/**/*`, which
// is TS6059, and no build had succeeded for as long as that was true.
//
+184
View File
@@ -0,0 +1,184 @@
// Linting runs in Docker, one way, everywhere: `script/lint` builds
// `Dockerfile.lint`, which COPYs the repo into a digest-pinned image and runs
// eslint and prettier as build steps, so a successful build IS a clean lint.
//
// Three things can quietly undo that, and none of them shows up as a build
// failure, which is why they are asserted here:
//
// 1. Recursion. `script/check` calls `script/lint`, and `script/lint` is now a
// `docker build`. Anything that runs `make check` inside a container is
// therefore asking for Docker inside Docker, and CI breaks. The image built
// from `Dockerfile` runs the suite and the compile only; lint happens once,
// in `Dockerfile.lint`.
// 2. Cache. A lint build over an unchanged tree returns success in well under a
// second having linted nothing. The `LINT_EPOCH` guard is what forces the
// linter layers to execute, and it has to fail closed: an unset build
// argument is the empty string, which is a perfectly stable cache key, so an
// invocation that omits it must be rejected rather than served a cached
// green.
// 3. A host lint path surviving alongside the container one, which would let a
// lint result come from an unpinned local toolchain.
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { join } from "node:path";
const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
const read = (name: string): string =>
readFileSync(join(repoRoot, name), "utf-8");
// The executable lines of a shell script or Dockerfile: comments carry the
// reasoning and frequently name the very commands these tests forbid, so they
// would otherwise trigger every assertion below.
const instructions = (name: string): string[] =>
read(name)
.split("\n")
.map((line) => line.trim())
.filter((line) => line !== "" && !line.startsWith("#"));
const lintScript = instructions("script/lint");
const dockerfileLint = instructions("Dockerfile.lint");
const dockerfile = instructions("Dockerfile");
const cibuild = instructions("script/cibuild");
const has = (lines: string[], pattern: RegExp): boolean =>
lines.some((line) => pattern.test(line));
describe("script/lint", () => {
it("lints by building Dockerfile.lint", () => {
expect(has(lintScript, /docker build .*-f Dockerfile\.lint/)).toBe(
true,
);
});
// The whole point of the ruling: no invocation of a linter against the
// working tree survives, so a lint verdict can only come from the pinned
// image.
it("runs no linter on the host", () => {
expect(has(lintScript, /eslint|prettier/)).toBe(false);
});
// Without a fresh epoch the build is served from cache in under a second,
// having linted nothing, and still exits 0.
it("passes a fresh LINT_EPOCH on every run", () => {
expect(
has(lintScript, /--build-arg LINT_EPOCH="\$\(date \+%s\)"/),
).toBe(true);
});
});
describe("Dockerfile.lint", () => {
// Tag references are server-mutable, so they are remote code execution.
it("pins its base image by digest", () => {
expect(has(dockerfileLint, /^FROM \S+@sha256:[0-9a-f]{64}/)).toBe(true);
});
it("runs eslint as a build step", () => {
expect(has(dockerfileLint, /^RUN .*eslint \./)).toBe(true);
});
it("runs prettier as a build step", () => {
expect(has(dockerfileLint, /^RUN .*prettier --check \./)).toBe(true);
});
// An unset ARG is the empty string, and an empty string is a perfectly
// stable cache key. Rejecting it is what stops a bare
// `docker build -f Dockerfile.lint .` from reporting a green it did not
// earn.
it("refuses to build without LINT_EPOCH", () => {
expect(has(dockerfileLint, /^ARG LINT_EPOCH$/)).toBe(true);
expect(
has(dockerfileLint, /^RUN \[ -n "\$LINT_EPOCH" \] \|\| exit 1$/),
).toBe(true);
});
// The guard only forces execution of the layers below it, so both linters
// have to sit after it. Layer order is the mechanism, not a style choice.
it("puts both linters below the epoch guard", () => {
const guard = dockerfileLint.findIndex((line) =>
/^RUN \[ -n "\$LINT_EPOCH" \]/.test(line),
);
const linters = dockerfileLint
.map((line, index) => ({ line, index }))
.filter(({ line }) => /^RUN .*(eslint|prettier)/.test(line));
expect(linters.length).toBeGreaterThan(0);
for (const { line, index } of linters) {
expect(
index,
`${line} must run below the LINT_EPOCH guard`,
).toBeGreaterThan(guard);
}
});
// Dependency installation is the slow layer and has nothing to do with the
// sources, so it caches separately: manifests first, sources afterwards.
it("copies the manifests before the sources", () => {
const manifests = dockerfileLint.findIndex((line) =>
/^COPY package\.json yarn\.lock/.test(line),
);
const sources = dockerfileLint.findIndex((line) =>
/^COPY \. \.$/.test(line),
);
expect(manifests).toBeGreaterThanOrEqual(0);
expect(sources).toBeGreaterThan(manifests);
});
// script/lint is a docker build; a lint step that shelled out to it would
// recurse.
it("does not call script/lint or make lint", () => {
expect(has(dockerfileLint, /make lint|script\/lint/)).toBe(false);
});
});
describe("Dockerfile", () => {
// `make check` runs script/lint, which is a docker build, so an image that
// ran it would need a Docker daemon inside the container.
it("does not run make check, make lint or script/lint", () => {
expect(
has(dockerfile, /make check|make lint|script\/(check|lint)/),
).toBe(false);
});
// The replaced lint stage took a `COPY --from=lint` dependency to order
// itself before the check stage. Dockerfile.lint is that stage now, and
// two definitions of how to lint is one too many.
it("has no lint stage", () => {
expect(has(dockerfile, /AS lint\b|--from=lint\b/)).toBe(false);
});
it("still runs the suite and the build under the epoch guard", () => {
expect(has(dockerfile, /^RUN make test$/)).toBe(true);
expect(has(dockerfile, /^RUN make build$/)).toBe(true);
expect(
has(dockerfile, /^RUN \[ -n "\$CHECK_EPOCH" \] \|\| exit 1$/),
).toBe(true);
});
});
describe("script/cibuild", () => {
// CI has to get both verdicts. Lint goes first so the fast failure is
// reported before the suite runs.
it("builds the lint image before the test and build image", () => {
const lint = cibuild.findIndex((line) => /\/lint"/.test(line));
const check = cibuild.findIndex((line) =>
/docker build .*CHECK_EPOCH/.test(line),
);
expect(lint).toBeGreaterThanOrEqual(0);
expect(check).toBeGreaterThan(lint);
});
});
describe("package.json", () => {
// `yarn lint` was a second, unpinned way to get a lint verdict, from
// whatever eslint the working tree happened to have installed.
it("exposes no host lint script", () => {
const pkg = JSON.parse(read("package.json")) as {
scripts: Record<string, string>;
};
expect(pkg.scripts.lint).toBeUndefined();
});
});
+503
View File
@@ -0,0 +1,503 @@
// `make check` used to run `prettier --check .` twice: once inside the lint
// container (`script/lint` builds `Dockerfile.lint`, which runs eslint and
// prettier as build steps) and once again on the host, because `script/check`
// also called `script/fmt-check`. Two passes, one verdict, and the host one is
// the weaker of the two — its prettier is whatever the working tree happens to
// have installed, while the container's is digest-pinned and installed under
// `--frozen-lockfile`.
//
// The fix was to delete the host call from `script/check` and `script/precommit`.
// Nothing about that fix is self-enforcing: anyone can wire `script/fmt-check`
// back in, or add a prettier step to a Dockerfile, and every build stays green
// while quietly doing the work twice again. So the count is asserted here
// rather than promised in a comment.
//
// The assertion is a static walk of the invocation graph, not a string match
// against one file. Starting from an entrypoint, it follows every edge the repo
// actually uses to reach another command — `run:` steps in the CI workflow,
// `"$SCRIPT_DIR/<name>"` and `script/<name>` into other scripts, `make <target>`
// through the Makefile shims, `yarn run <name>` through the `package.json`
// scripts, and `docker build -f <file>` into that Dockerfile's `RUN` steps — and
// counts the prettier invocations it finds. A prettier call added anywhere in
// that graph is therefore caught, wherever it is added.
//
// Two entrypoints are walked, because they cover different graphs: `make check`
// is what a developer runs, and `.gitea/workflows/check.yml` is what CI runs.
// The CI walk starts at the workflow file rather than at a hand-picked script,
// so "the path CI executes" is read out of the repo instead of assumed; it
// reaches `script/cibuild`, and through it the `Dockerfile` image that `make
// check` never touches. Walking only `make check` is how a duplicate prettier
// pass in `Dockerfile` stayed invisible.
//
// Undercounting is the failure mode that would make this test worthless. Three
// things guard against it: the walk is asserted to have reached the nodes that
// matter, an unresolvable or empty node is a thrown error rather than a quiet
// zero, and prettier is counted per occurrence rather than per line, so two
// invocations chained with `&&` cannot read as one.
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { join } from "node:path";
const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
const read = (name: string): string =>
readFileSync(join(repoRoot, name), "utf-8");
// A backslash at end of line continues the command; the resolver has to see the
// whole invocation, since the interesting flags (`-f Dockerfile.lint`) can sit
// on the continuation.
const joinContinuations = (text: string): string[] => {
const joined: string[] = [];
for (const raw of text.split("\n")) {
const line = raw.trim();
const previous = joined[joined.length - 1];
if (previous !== undefined && previous.endsWith("\\")) {
joined[joined.length - 1] =
`${previous.slice(0, -1).trim()} ${line}`;
} else {
joined.push(line);
}
}
return joined;
};
// Comments are stripped everywhere. The headers of these scripts explain the
// duplication this test exists to prevent, and therefore name `prettier` and
// `script/fmt-check` repeatedly; counting them would make the test assert the
// prose instead of the behaviour.
const executable = (text: string): string[] =>
joinContinuations(text).filter(
(line) => line !== "" && !line.startsWith("#"),
);
// Every occurrence, not "does this line mention prettier": a line that reads
// `yarn run prettier --check . && yarn run prettier --check src` is two passes
// over the same tree, which is exactly the bug this file exists to catch, and
// counting it as one would hide it. `.prettierrc` and `.prettierignore` are not
// invocations and do not match, because `\b` requires a non-word character
// after the name.
const countPrettier = (line: string): number =>
(line.match(/\bprettier\b/g) ?? []).length;
// Makefile targets are thin shims (`check:` / tab / `@script/check`), so a
// `make <target>` edge has to resolve through them to keep "per `make check`"
// meaning what it says. Recipe lines are the tab-indented ones.
const makeRecipes = (): Map<string, string[]> => {
const recipes = new Map<string, string[]>();
let current: string | null = null;
for (const raw of read("Makefile").split("\n")) {
if (raw.startsWith("\t")) {
if (current !== null) {
recipes.get(current)?.push(raw.trim().replace(/^[@-]+/, ""));
}
continue;
}
const target = /^([a-z][a-z-]*)\s*:(?!=)/.exec(raw);
current = target === null ? null : target[1];
if (current !== null && !recipes.has(current)) {
recipes.set(current, []);
}
}
return recipes;
};
const recipes = makeRecipes();
const packageScripts = (): Record<string, string> => {
const pkg = JSON.parse(read("package.json")) as {
scripts?: Record<string, string>;
};
return pkg.scripts ?? {};
};
const scripts = packageScripts();
// Node keys: `script/<name>`, `docker:<Dockerfile>`, `make:<target>`,
// `yarn:<package.json script>`, `workflow:<CI workflow file>`.
const resolve = (node: string): string[] => {
if (node.startsWith("script/")) return executable(read(node));
if (node.startsWith("docker:")) {
return executable(read(node.slice("docker:".length)))
.filter((line) => line.startsWith("RUN "))
.map((line) => line.slice("RUN ".length));
}
// The `run:` steps of a workflow, in file order. `uses:` steps are actions,
// not commands, and have no edges into this repo's graph. A `run: |` block
// would resolve to the bare `|`, which reaches nothing and therefore fails
// the count rather than passing quietly.
if (node.startsWith("workflow:")) {
return executable(read(node.slice("workflow:".length)))
.filter((line) => /^-?\s*run:\s*\S/.test(line))
.map((line) => line.replace(/^-?\s*run:\s*/, ""));
}
if (node.startsWith("make:")) {
const target = node.slice("make:".length);
const recipe = recipes.get(target);
// A renamed or deleted target must be a loud failure: silently walking
// an empty recipe would report zero prettier invocations, which reads
// like the tidiest possible result.
if (recipe === undefined) {
throw new Error(`no such Makefile target: ${target}`);
}
return recipe;
}
if (node.startsWith("yarn:")) {
const name = node.slice("yarn:".length);
const script = scripts[name];
if (script === undefined) {
throw new Error(`no such package.json script: ${name}`);
}
return [script];
}
throw new Error(`unresolvable node: ${node}`);
};
// Same reasoning as the missing-target error, applied to every node kind: a
// node that resolves to no commands contributes zero prettier invocations and
// zero edges, which is indistinguishable from a clean result. Fail instead.
const commandsOf = (node: string): string[] => {
const commands = resolve(node);
if (commands.length === 0) {
throw new Error(`node resolved to no commands: ${node}`);
}
return commands;
};
const edgesOf = (line: string): string[] => {
const edges: string[] = [];
// `"$SCRIPT_DIR/lint"`, `"$ROOT/script/lint"` and a bare `script/lint` are
// all the same edge.
for (const match of line.matchAll(
/(?:\$SCRIPT_DIR|\$\{SCRIPT_DIR\}|script)\/([a-z][a-z-]*)/g,
)) {
edges.push(`script/${match[1]}`);
}
// Only real targets: `pkg_install gnumake make make make` in
// script/bootstrap is a package name, not an invocation of this Makefile.
for (const match of line.matchAll(/\bmake\s+([a-z][a-z-]*)/g)) {
if (recipes.has(match[1] ?? "")) edges.push(`make:${match[1]}`);
}
// Same rule for yarn: `yarn run prettier` is the linter itself (counted,
// not followed), `yarn run fmt-check` would be a package.json script that
// runs it indirectly.
for (const match of line.matchAll(/\byarn(?:\s+run)?\s+([a-z][a-z-]*)/g)) {
if ((match[1] ?? "") in scripts) edges.push(`yarn:${match[1]}`);
}
// The container lint pass lives behind a `docker build`; without following
// it the count would miss the one invocation that is supposed to survive.
if (/\bdocker\s+build\b/.test(line)) {
const file = /\s-f\s+(\S+)/.exec(line);
edges.push(`docker:${file === null ? "Dockerfile" : file[1]}`);
}
return edges;
};
interface Walk {
prettier: number;
reached: Set<string>;
}
// Repeated invocations must count repeatedly — running the same script twice is
// exactly the bug — so nodes are not deduplicated. The path stack is only there
// to turn a cycle into a loud failure instead of a hang.
//
// Counting and edge-following both happen for every line: a line that invokes
// prettier can also invoke something else, and skipping the edges of counted
// lines silently truncated the graph.
const walk = (node: string, path: string[] = [], into?: Walk): Walk => {
const result = into ?? { prettier: 0, reached: new Set<string>() };
if (path.includes(node)) {
throw new Error(`invocation cycle: ${[...path, node].join(" -> ")}`);
}
result.reached.add(node);
for (const line of commandsOf(node)) {
result.prettier += countPrettier(line);
for (const edge of edgesOf(line)) {
walk(edge, [...path, node], result);
}
}
return result;
};
describe("prettier runs exactly once per make check", () => {
const check = walk("make:check");
// The headline assertion, and the one the issue is about.
it("invokes prettier once for the whole of make check", () => {
expect(check.prettier).toBe(1);
});
// Guards against the count being 1 (or 0) because the walk never got
// anywhere. `make check` has to reach the suite, the lint script, and the
// Dockerfile whose build IS the lint verdict.
it.each(["script/check", "script/test", "script/lint", "Dockerfile.lint"])(
"reaches %s while counting",
(node) => {
const key = node.startsWith("script/") ? node : `docker:${node}`;
expect([...check.reached]).toContain(key);
},
);
// The one that survives is the container's, not the host's: that is the
// authoritative verdict, since a successful Dockerfile.lint build is what
// CI treats as proof of a clean tree.
it("keeps the surviving invocation inside the lint container", () => {
expect(walk("docker:Dockerfile.lint").prettier).toBe(1);
});
it("does not reach the host formatting check from make check", () => {
expect([...check.reached]).not.toContain("script/fmt-check");
});
});
describe("prettier runs exactly once per CI build", () => {
// Rooted at the workflow file, so this is the graph CI executes rather than
// the graph someone believed CI executes. `make check` cannot stand in for
// it: CI runs script/cibuild, which builds Dockerfile as well as
// Dockerfile.lint, and nothing under `make check` ever reads Dockerfile.
const ci = walk("workflow:.gitea/workflows/check.yml");
it("invokes prettier once for the whole CI build", () => {
expect(ci.prettier).toBe(1);
});
// script/cibuild is here because the workflow is asserted to run it;
// Dockerfile is here because it is the half of the CI graph that the
// `make check` walk cannot see.
it.each([
"script/cibuild",
"script/lint",
"docker:Dockerfile.lint",
"docker:Dockerfile",
])("reaches %s while counting", (node) => {
expect([...ci.reached]).toContain(node);
});
// The test and build image must not lint: linting is Dockerfile.lint's job,
// and a prettier step added here would be a second pass over the same tree
// for the same verdict — on the one path where it matters most.
it("keeps prettier out of the test and build image", () => {
expect(walk("docker:Dockerfile").prettier).toBe(0);
});
});
describe("the standalone entrypoints still do what their names say", () => {
// REPO_POLICIES.md requires both `make lint` and `make fmt-check` to exist
// and mean something. Dropping fmt-check from script/check must not turn it
// into a target nobody can use, and must not leave `make check` passing
// because both halves became no-ops.
it("still checks formatting under make fmt-check", () => {
expect(walk("make:fmt-check").prettier).toBe(1);
});
it("still checks formatting under make lint", () => {
expect(walk("make:lint").prettier).toBe(1);
});
});
describe("script/precommit", () => {
// Same duplication as script/check, same fix. The hook still catches a
// badly formatted tree before the commit lands, because script/lint is the
// container prettier run — that is the whole reason the host call could go.
it("checks formatting exactly once", () => {
expect(walk("script/precommit").prettier).toBe(1);
});
it("gets that check from the lint container", () => {
expect([...walk("script/precommit").reached]).toContain(
"docker:Dockerfile.lint",
);
});
});
// script/bootstrap installs the dependencies, and it has two install sites: one
// for the case where yarn has to be reached through nvm, and one for the case
// where yarn is already on PATH. A substring check against the whole file
// cannot tell them apart, so it reports the first and says nothing about the
// second — which is the one the containers take, because the pinned node image
// ships yarn. Both are resolved separately here.
const installBranches = (): { withoutYarn: string[]; withYarn: string[] } => {
const lines = executable(read("script/bootstrap"));
const open = lines.findIndex((line) =>
/^install_js_deps\s*\(\)/.test(line),
);
if (open === -1) {
throw new Error("script/bootstrap: no install_js_deps function");
}
const close = lines.indexOf("}", open);
const body = lines.slice(open + 1, close === -1 ? undefined : close);
const guard = body.findIndex((line) =>
/^if\b.*\bmissing yarn\b/.test(line),
);
const otherwise = body.indexOf("else", guard);
const end = body.indexOf("fi", otherwise);
if (guard === -1 || otherwise === -1 || end === -1) {
throw new Error(
"script/bootstrap: install_js_deps is not the expected " +
"if missing yarn / else / fi shape",
);
}
return {
withoutYarn: body.slice(guard + 1, otherwise),
withYarn: body.slice(otherwise + 1, end),
};
};
// Every `yarn install` in the given lines, with its flags, so an unpinned
// install cannot hide next to a pinned one.
const yarnInstalls = (lines: string[]): string[] =>
lines.flatMap((line) =>
[...line.matchAll(/\byarn install\b[^"'&|;]*/g)].map((match) =>
match[0].trim(),
),
);
describe("host and container prettier cannot disagree", () => {
// With the host pass gone from `make check`, `make fmt-check` is the only
// host-side formatting check left, and the container is the gate. The two
// must keep producing the same verdict on the same tree, or a developer
// running `make fmt-check` gets a green that CI then rejects.
//
// Three things make them agree, and all three are load-bearing:
it("pins the same prettier for both", () => {
const pkg = JSON.parse(read("package.json")) as {
devDependencies: Record<string, string>;
};
// An exact version, not a range: `^3.8.1` would let the container and
// the host resolve different builds with different formatting.
expect(pkg.devDependencies.prettier).toMatch(/^\d+\.\d+\.\d+$/);
});
it("installs from the lockfile on the branch the container takes", () => {
// Both images are FROM a node image, which ships yarn, so `missing
// yarn` is false and this is the branch that runs in the container.
const installs = yarnInstalls(installBranches().withYarn);
expect(installs).not.toHaveLength(0);
for (const install of installs) {
expect(install).toContain("--frozen-lockfile");
}
});
it("installs from the lockfile on the nvm branch too", () => {
// Not the container's branch, but it is the one a developer without
// yarn on PATH gets, and their prettier has to match the container's.
const installs = yarnInstalls(installBranches().withoutYarn);
expect(installs).not.toHaveLength(0);
for (const install of installs) {
expect(install).toContain("--frozen-lockfile");
}
});
it("runs script/bootstrap inside the lint container", () => {
// Without this the lockfile assertions above would be about a script
// the container never executes.
expect([...walk("docker:Dockerfile.lint").reached]).toContain(
"script/bootstrap",
);
});
it("keeps .gitignore in the build context", () => {
// Prettier 3 reads .gitignore as a default ignore file, so excluding it
// from the context would change which files the container checks.
const dockerignore = read(".dockerignore")
.split("\n")
.map((line) => line.trim());
expect(dockerignore).not.toContain(".gitignore");
});
});
describe("the walk cannot pass vacuously", () => {
// An earlier draft of this file computed a Makefile target as
// `node.slice("make:")` — a string where a number belongs, which coerces to
// NaN and made every target resolve to nothing. The count went to zero and
// an assertion of "not twice" would have been satisfied by a walk that had
// read nothing at all. Every way of reaching nothing is therefore an
// error here, and the ways are tested rather than assumed.
it("reports zero for a subgraph that does not run prettier", () => {
expect(walk("make:clean").prettier).toBe(0);
});
it("refuses a Makefile target that does not exist", () => {
expect(() => walk("make:no-such-target")).toThrow(
/no such Makefile target/,
);
});
it("refuses a package.json script that does not exist", () => {
expect(() => walk("yarn:no-such-script")).toThrow(
/no such package.json script/,
);
});
it("refuses a script that does not exist", () => {
expect(() => walk("script/no-such-script")).toThrow(/ENOENT/);
});
it("refuses a node that resolves to no commands", () => {
// .dockerignore has no RUN steps, standing in for a Dockerfile whose
// steps a restructure moved somewhere the resolver cannot see.
expect(() => walk("docker:.dockerignore")).toThrow(
/resolved to no commands/,
);
});
it("refuses a node kind it does not understand", () => {
expect(() => walk("nonsense")).toThrow(/unresolvable node/);
});
it("refuses to walk in circles", () => {
expect(() => walk("make:check", ["script/check"])).toThrow(
/invocation cycle/,
);
});
});
describe("the resolver reads what the shell would run", () => {
// Counting per line is how `yarn run prettier --check . && yarn run
// prettier --check src` read as a single invocation.
it("counts every prettier invocation on a line", () => {
expect(
countPrettier(
"yarn run prettier --check . && yarn run prettier --check src",
),
).toBe(2);
});
it("does not count the config files as invocations", () => {
expect(countPrettier("COPY .prettierrc .prettierignore ./")).toBe(0);
});
// The counting `continue` also dropped every edge that shared a line with a
// prettier call, so a whole subtree could be hidden behind one `&&`.
it("still follows the edges of a line that invokes prettier", () => {
expect(
edgesOf('yarn run prettier --check . && "$SCRIPT_DIR/lint"'),
).toContain("script/lint");
});
it("resolves every spelling of a script call to one node", () => {
expect(
edgesOf('"$SCRIPT_DIR/lint" "${SCRIPT_DIR}/test" script/fmt'),
).toEqual(["script/lint", "script/test", "script/fmt"]);
});
it("follows a bare docker build to Dockerfile and -f to its file", () => {
expect(edgesOf("docker build .")).toContain("docker:Dockerfile");
expect(edgesOf("docker build -f Dockerfile.lint .")).toContain(
"docker:Dockerfile.lint",
);
});
it("reads the run steps of the CI workflow and not its uses steps", () => {
expect(commandsOf("workflow:.gitea/workflows/check.yml")).toEqual([
"script/cibuild",
]);
});
});
+50
View File
@@ -0,0 +1,50 @@
// A checkout nested under `.claude/` must not add its tests to this suite.
// The test plants one in a temporary directory next to a real test file and
// asks vitest, with this repo's config, which test files it would run.
import { afterEach, describe, expect, it } from "vitest";
import { execFileSync } from "node:child_process";
import { 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 root = "";
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
const writeTest = (path: string): void => {
mkdirSync(join(root, path, ".."), { recursive: true });
writeFileSync(
join(root, path),
'import { it } from "vitest";\nit("runs", () => {});\n',
);
};
describe("vitest.config.ts", () => {
it("does not collect tests from a checkout nested under .claude/", () => {
root = mkdtempSync(join(tmpdir(), "quak-nested-checkout-"));
writeTest("test/real.test.ts");
writeTest(".claude/worktrees/other/test/real.test.ts");
const output = execFileSync(
process.execPath,
[
join(repoRoot, "node_modules/vitest/vitest.mjs"),
"list",
"--filesOnly",
"--config",
join(repoRoot, "vitest.config.ts"),
"--root",
root,
],
{ cwd: root, encoding: "utf-8" },
);
const files = output.split("\n").filter((line) => line !== "");
expect(files).toEqual(["test/real.test.ts"]);
});
});
+75 -4
View File
@@ -149,8 +149,11 @@ describe("isRetryable: transport failures", () => {
});
it("retries an errno carried on the error itself", () => {
// Every errno the classifier names, so none can be reclassified
// unnoticed.
for (const code of [
"ECONNRESET",
"ECONNABORTED",
"ETIMEDOUT",
"EPIPE",
"ENOTFOUND",
@@ -158,6 +161,8 @@ describe("isRetryable: transport failures", () => {
"ECONNREFUSED",
"EHOSTUNREACH",
"ENETUNREACH",
"ENETRESET",
"ENETDOWN",
]) {
expect(isRetryable(errnoError(code))).toBe(true);
}
@@ -215,6 +220,27 @@ describe("isRetryable: transport failures", () => {
looped.cause = looped;
expect(isRetryable(looped)).toBe(false);
});
it("terminates on a cause chain that loops through two errors", () => {
const first: Error & { cause?: unknown } = new Error("first");
const second = new Error("second", { cause: first });
first.cause = second;
expect(isRetryable(first)).toBe(false);
});
it("reads the error and at most seven causes below it", () => {
// The walk is bounded at eight links. An errno at the eighth link is
// found; one at the ninth is not.
const buried = (causes: number): Error => {
let err = errnoError("ECONNRESET");
for (let i = 0; i < causes; i++) {
err = new Error(`wrapper ${i}`, { cause: err });
}
return err;
};
expect(isRetryable(buried(7))).toBe(true);
expect(isRetryable(buried(8))).toBe(false);
});
});
describe("isRetryable: stream truncation versus corruption", () => {
@@ -279,10 +305,9 @@ describe("isSafeToReplay", () => {
* that is not the whole question: the other half is "could the first
* attempt already have taken effect on the server?".
*
* quak's non-idempotent calls are `/users/srp/create-session`,
* `/users/two-factor/verify` (which consumes one of a limited number of
* 2FA attempts) and `/files/thumbnail`. A blind replay of any of them can
* do real damage, so they retry only on the failures that establish no TCP
* The calls this guards are the `POST` and `PUT` requests listed in the
* README under "Endpoints used". A blind replay of some of them can do
* real damage, so they retry only on the failures that establish no TCP
* connection to the server ever existed DNS produced no address, or the
* peer refused the connection and therefore that no request byte can
* have been transmitted.
@@ -333,6 +358,52 @@ describe("isSafeToReplay", () => {
).toBe(false);
expect(isSafeToReplay(new TypeError("fetch failed"))).toBe(false);
});
it("does not replay any other errno the classifier names", () => {
for (const code of [
"ECONNRESET",
"ECONNABORTED",
"ETIMEDOUT",
"EPIPE",
"EHOSTUNREACH",
"ENETUNREACH",
"ENETRESET",
"ENETDOWN",
]) {
expect(isSafeToReplay(errnoError(code))).toBe(false);
}
});
it("does not replay a chain that also shows the request may have gone out", () => {
// A connect errno somewhere in the chain is not enough: any other
// errno beside it is doubt, and doubt is not replayed.
const reset = Object.assign(
new Error("read ECONNRESET", { cause: errnoError("ECONNREFUSED") }),
{ code: "ECONNRESET" },
);
const mixed = new TypeError("fetch failed", { cause: reset });
expect(isRetryable(mixed)).toBe(true);
expect(isSafeToReplay(mixed)).toBe(false);
});
it("does not replay a chain longer than the walk reads", () => {
// Eight connect errnos, then a reset at the ninth link, below the
// limit. The walk never sees the reset, so it cannot rule it out.
const refusedChain = (below: Error | undefined): Error => {
let err = below;
for (let i = 0; i < 8; i++) {
err = Object.assign(new Error(`refused ${i}`, { cause: err }), {
code: "ECONNREFUSED",
});
}
return err as Error;
};
expect(isSafeToReplay(refusedChain(errnoError("ECONNRESET")))).toBe(
false,
);
// The same eight links with nothing below them are replayable.
expect(isSafeToReplay(refusedChain(undefined))).toBe(true);
});
});
// ---------------------------------------------------------------------------
+175 -83
View File
@@ -6,17 +6,30 @@
* working thumbnails, others return 404 or empty bodies. The tests
* verify that the detection and repair logic handles each case correctly.
*
* As of issue #52 both helpers take an open `Library` for enumeration and the
* `Client` for the API operations that stay unchanged (the thumbnail existence
* check, and the encrypt-and-upload path). `fixMissingThumbnails` reads each
* original through the library's content cache (`photo.original()`).
*
* `fixMissingThumbnails` is the most complex function in quak: it
* downloads the original file, generates a JPEG thumbnail with jpeg-js,
* encrypts it with secretstream push, gets a presigned upload URL,
* uploads to S3, and registers the new thumbnail with the API. The
* test verifies each step actually happened and the uploaded data is
* a valid encrypted blob that decrypts to a JPEG.
*
* It regenerates thumbnails for baseline JPEGs only, because `jpeg-js` decodes
* only JPEG. A non-JPEG image (PNG, HEIC) or a video is reported as "skipped
* (unsupported)" rather than crashing the decoder into an opaque failure
* (issue #17); the mixed test below locks that distinction down.
*/
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import sodium from "libsodium-wrappers-sumo";
import * as jpegJs from "jpeg-js";
import { beforeAll, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
init,
toBase64,
@@ -27,6 +40,7 @@ import {
} from "../../src/crypto/index.js";
import { SRP, SrpServer } from "fast-srp-hap";
import { Client } from "../../src/client.js";
import { Library } from "../../src/library/index.js";
import {
listMissingThumbnails,
fixMissingThumbnails,
@@ -42,6 +56,7 @@ const TEST_EMAIL = "thumb@example.com";
const TEST_PASSWORD = "thumbpass";
const TEST_OPS = 2;
const TEST_MEM = 64 * 1024 * 1024;
const TEST_TIME = 1700000000000000;
interface ThumbMockState {
verifier: Buffer;
@@ -63,8 +78,17 @@ interface ThumbMockState {
}
let mock: ThumbMockState;
let tmpRoot: string;
const buildThumbMock = async (): Promise<ThumbMockState> => {
// PNG signature bytes — enough for `fixMissingThumbnails` to recognise a
// non-JPEG image and skip it. It need not be a decodable PNG.
const PNG_BYTES = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
]);
const buildThumbMock = async (opts?: {
extraFormats?: boolean;
}): Promise<ThumbMockState> => {
const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
const kek = await deriveKEK(TEST_PASSWORD, kekSalt, TEST_OPS, TEST_MEM);
const loginSubKeyBytes = deriveLoginSubkey(kek);
@@ -102,7 +126,6 @@ const buildThumbMock = async (): Promise<ThumbMockState> => {
opsLimit: TEST_OPS,
};
// One collection with 3 files: ok thumbnail, empty thumbnail, 404 thumbnail
const collKey = sodium.crypto_secretbox_keygen();
const ckN = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const encCK = sodium.crypto_secretbox_easy(collKey, ckN, masterKey);
@@ -118,10 +141,11 @@ const buildThumbMock = async (): Promise<ThumbMockState> => {
encryptedName: toBase64(encCN),
nameDecryptionNonce: toBase64(cnN),
type: "album",
updationTime: 1700000000000000,
updationTime: TEST_TIME,
};
// Generate a real tiny JPEG via jpeg-js
// Generate a real tiny JPEG via jpeg-js, used as the encrypted body of the
// JPEG files so a repair actually decodes and re-encodes real pixels.
const w = 100;
const h = 80;
const pixels = new Uint8Array(w * h * 4);
@@ -131,26 +155,32 @@ const buildThumbMock = async (): Promise<ThumbMockState> => {
pixels[i + 2] = 0; // B
pixels[i + 3] = 255; // A
}
const tinyJpeg = jpegJs.encode(
{ data: pixels, width: w, height: h },
80,
).data;
const tinyJpeg = new Uint8Array(
jpegJs.encode({ data: pixels, width: w, height: h }, 80).data,
);
const fileKeys: Record<number, Uint8Array> = {};
const fileCiphertexts: Record<number, Uint8Array> = {};
const rawFiles: Record<string, unknown>[] = [];
for (const fileID of [100, 101, 102]) {
// Build one raw file record: encrypt its metadata and its body under a
// fresh per-file key, and record the key and ciphertext for the mock to
// serve and for the test to verify against.
const makeRawFile = (
fileID: number,
fileType: number,
title: string,
body: Uint8Array,
): Record<string, unknown> => {
const fk = sodium.crypto_secretstream_xchacha20poly1305_keygen();
fileKeys[fileID] = fk;
const fkN = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const encFK = sodium.crypto_secretbox_easy(fk, fkN, collKey);
const meta = JSON.stringify({
title: `file-${fileID}.jpg`,
fileType: 0,
creationTime: 1700000000000000,
modificationTime: 1700000000000000,
title,
fileType,
creationTime: TEST_TIME,
modificationTime: TEST_TIME,
});
const metaPush =
sodium.crypto_secretstream_xchacha20poly1305_init_push(fk);
@@ -161,18 +191,17 @@ const buildThumbMock = async (): Promise<ThumbMockState> => {
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
);
// Encrypt the tiny JPEG as the file body
const filePush =
sodium.crypto_secretstream_xchacha20poly1305_init_push(fk);
const encFile = sodium.crypto_secretstream_xchacha20poly1305_push(
filePush.state,
new Uint8Array(tinyJpeg),
body,
null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
);
fileCiphertexts[fileID] = encFile;
rawFiles.push({
return {
id: fileID,
collectionID: 1,
ownerID: 42,
@@ -186,8 +215,30 @@ const buildThumbMock = async (): Promise<ThumbMockState> => {
thumbnail: {
decryptionHeader: toBase64(sodium.randombytes_buf(24)),
},
updationTime: 1700000000000000,
});
updationTime: TEST_TIME,
};
};
// Three JPEG files: ok thumbnail, empty thumbnail, 404 thumbnail.
const rawFiles: Record<string, unknown>[] = [];
for (const fileID of [100, 101, 102]) {
rawFiles.push(makeRawFile(fileID, 0, `file-${fileID}.jpg`, tinyJpeg));
}
const thumbnailBehavior: Record<number, "ok" | "empty" | "404" | "500"> = {
100: "ok",
101: "empty",
102: "404",
};
// For the issue #17 mixed test: a non-JPEG image and a video, both with a
// missing (404) thumbnail so they surface in the missing list too.
if (opts?.extraFormats) {
rawFiles.push(makeRawFile(103, 0, "file-103.png", PNG_BYTES));
rawFiles.push(
makeRawFile(104, 1, "file-104.mp4", new Uint8Array([0, 0, 0, 1])),
);
thumbnailBehavior[103] = "404";
thumbnailBehavior[104] = "404";
}
return {
@@ -206,11 +257,7 @@ const buildThumbMock = async (): Promise<ThumbMockState> => {
filesByCollection: { 1: rawFiles },
fileCiphertexts,
fileKeys,
thumbnailBehavior: {
100: "ok",
101: "empty",
102: "404",
},
thumbnailBehavior,
uploadedThumbnails: [],
};
};
@@ -381,6 +428,31 @@ const countingFetch = (
return { fetch: fake as typeof globalThis.fetch, matched: () => matched };
};
// Open a library over a mock-backed client. As the CLI does for point commands,
// the background precache is off and the refresh interval is long, and the
// library client omits `fetchMLData` so no background ML fetch runs. The real
// `Client` is still used for the API operations the helpers perform directly.
const openLib = (client: Client): Promise<Library> =>
Library.open({
client: {
whoami: () => client.whoami(),
collectionsSince: (args) => client.collectionsSince(args),
filesSince: (args) => client.filesSince(args),
contentSource: () => client.contentSource(),
},
cacheDirectory: mkdtempSync(join(tmpRoot, "cache-")),
refreshIntervalSeconds: 3600,
precacheThumbnails: false,
precacheOriginals: false,
});
const login = (fetch: typeof globalThis.fetch, retry?: RetryOptions) =>
Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: retry ? { fetch, retry } : { fetch },
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -389,17 +461,21 @@ beforeAll(async () => {
await init();
await sodium.ready;
mock = await buildThumbMock();
tmpRoot = mkdtempSync(join(tmpdir(), "quak-thumb-test-"));
});
afterAll(() => {
if (tmpRoot && existsSync(tmpRoot))
rmSync(tmpRoot, { recursive: true, force: true });
});
describe("listMissingThumbnails", () => {
it("identifies files with empty and 404 thumbnails, ignores working ones", async () => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildThumbFetch(mock) },
});
const client = await login(buildThumbFetch(mock));
const lib = await openLib(client);
const missing = await listMissingThumbnails(client);
const missing = await listMissingThumbnails(lib, client);
lib.close();
// File 100 has a working thumbnail → not reported
// File 101 has an empty thumbnail → reported
@@ -436,13 +512,11 @@ describe("listMissingThumbnails", () => {
buildThumbFetch(failingMock),
(url) => url.includes("thumbnails.ente.io") && url.includes("102"),
);
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: counted.fetch, retry: { ...noWait } },
});
const client = await login(counted.fetch, { ...noWait });
const lib = await openLib(client);
const missing = await listMissingThumbnails(client);
const missing = await listMissingThumbnails(lib, client);
lib.close();
// Only the genuinely empty thumbnail is reported.
expect(missing.map((m) => m.fileID)).toEqual([101]);
@@ -475,13 +549,11 @@ describe("listMissingThumbnails", () => {
return inner(input, init);
}) as typeof globalThis.fetch;
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch, retry: { ...noWait } },
});
const client = await login(fetch, { ...noWait });
const lib = await openLib(client);
const missing = await listMissingThumbnails(client);
const missing = await listMissingThumbnails(lib, client);
lib.close();
expect(missing.map((m) => m.fileID)).toEqual([101]);
expect(thumbRequests).toBe(4);
@@ -500,13 +572,11 @@ describe("listMissingThumbnails", () => {
mockWithDupes.filesByCollection[2] =
mockWithDupes.filesByCollection[1]!;
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildThumbFetch(mockWithDupes) },
});
const client = await login(buildThumbFetch(mockWithDupes));
const lib = await openLib(client);
const missing = await listMissingThumbnails(client);
const missing = await listMissingThumbnails(lib, client);
lib.close();
// Should still be 2, not 4 (each file checked only once)
expect(missing.length).toBe(2);
@@ -516,16 +586,14 @@ describe("listMissingThumbnails", () => {
describe("fixMissingThumbnails", () => {
it("downloads original, generates thumbnail, encrypts, uploads, and registers", async () => {
const fixMock = await buildThumbMock();
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildThumbFetch(fixMock) },
});
const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client);
const results = await fixMissingThumbnails(client, [101]);
const results = await fixMissingThumbnails(lib, client, [101]);
lib.close();
expect(results.length).toBe(1);
expect(results[0]!.success).toBe(true);
expect(results[0]!.status).toBe("fixed");
expect(results[0]!.fileID).toBe(101);
expect(results[0]!.title).toBe("file-101.jpg");
expect(results[0]!.collection).toBe("Photos");
@@ -555,48 +623,76 @@ describe("fixMissingThumbnails", () => {
it("reports failure for nonexistent file IDs without crashing", async () => {
const fixMock = await buildThumbMock();
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildThumbFetch(fixMock) },
});
const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client);
const results = await fixMissingThumbnails(client, [999]);
const results = await fixMissingThumbnails(lib, client, [999]);
lib.close();
expect(results.length).toBe(1);
expect(results[0]!.success).toBe(false);
expect(results[0]!.status).toBe("failed");
expect(results[0]!.fileID).toBe(999);
expect(results[0]!.error).toContain("not found");
expect(results[0]!.reason).toContain("not found");
});
it("continues after one file fails and reports mixed results", async () => {
const fixMock = await buildThumbMock();
// Make file 102 fail by removing its ciphertext so download fails
// Make file 102 fail by removing its ciphertext so the download 404s.
delete fixMock.fileCiphertexts[102];
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildThumbFetch(fixMock) },
});
const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client);
const results = await fixMissingThumbnails(client, [101, 102]);
const results = await fixMissingThumbnails(lib, client, [101, 102]);
lib.close();
expect(results.length).toBe(2);
const success = results.find((r) => r.fileID === 101)!;
const failure = results.find((r) => r.fileID === 102)!;
expect(success.success).toBe(true);
expect(failure.success).toBe(false);
expect(success.status).toBe("fixed");
expect(failure.status).toBe("failed");
});
it("skips a non-JPEG image and a video as unsupported, not failed (issue #17)", async () => {
// A PNG and a video both throw inside the JPEG decoder. The helper must
// recognise them up front and report "skipped", distinct from a genuine
// "failed", and must not upload anything for them. The JPEG in the same
// batch is still repaired.
const fixMock = await buildThumbMock({ extraFormats: true });
const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client);
const results = await fixMissingThumbnails(
lib,
client,
[101, 103, 104],
);
lib.close();
const jpeg = results.find((r) => r.fileID === 101)!;
const png = results.find((r) => r.fileID === 103)!;
const video = results.find((r) => r.fileID === 104)!;
expect(jpeg.status).toBe("fixed");
// The PNG is a still image but not a JPEG: skipped only after its bytes
// are inspected.
expect(png.status).toBe("skipped");
expect(png.reason).toContain("JPEG");
// The video is skipped from its type alone, before any download.
expect(video.status).toBe("skipped");
expect(video.reason).toContain("video");
// Only the JPEG was uploaded; the two skipped files touched no upload.
expect(fixMock.uploadedThumbnails.length).toBe(1);
expect(fixMock.uploadedThumbnails[0]!.fileID).toBe(101);
});
});
describe("Client.getApiClient", () => {
it("returns the ApiClient when logged in", async () => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildThumbFetch(mock) },
});
const client = await login(buildThumbFetch(mock));
const api = client.getApiClient();
expect(api).toBeDefined();
@@ -604,11 +700,7 @@ describe("Client.getApiClient", () => {
});
it("throws after logout", async () => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildThumbFetch(mock) },
});
const client = await login(buildThumbFetch(mock));
client.logout();
expect(() => client.getApiClient()).toThrow(/logged out/);
+10
View File
@@ -0,0 +1,10 @@
import { configDefaults, defineConfig } from "vitest/config";
// vitest does not read .gitignore when looking for tests. A checkout nested
// under .claude/ has its own test/ tree, and without this exclude the suite
// runs once per nested checkout and still reports success.
export default defineConfig({
test: {
exclude: [...configDefaults.exclude, ".claude/**"],
},
});
+1 -8
View File
@@ -528,13 +528,6 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
"@types/libsodium-wrappers-sumo@0.8.2":
version "0.8.2"
resolved "https://registry.yarnpkg.com/@types/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.2.tgz#488e8747fbb982fe901020b5afeaddfa63da6830"
integrity sha512-uFOBpg/r21hExVlh2ty8YpDfSR+Yy3Jn8XS4+SSjitbhTxdYq+pBz/49XRxyUFe8SzqujHf/Wu0/O4d+FUtNfQ==
dependencies:
libsodium-wrappers-sumo "*"
"@types/node@22.18.13":
version "22.18.13"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.18.13.tgz#a037c4f474b860be660e05dbe92a9ef945472e28"
@@ -1249,7 +1242,7 @@ libsodium-sumo@^0.8.0:
resolved "https://registry.yarnpkg.com/libsodium-sumo/-/libsodium-sumo-0.8.4.tgz#6d4687781fa0ad398af14a7df872d5c27cf8cd31"
integrity sha512-TMtHShQfVVsaxDygyapvUC3o7YsPgXa/hRWeIgzyFz6w5k/1hirGptCxp1U7XwW3rCskaTTYKgV10v86UiGgNw==
libsodium-wrappers-sumo@*, libsodium-wrappers-sumo@0.8.4:
libsodium-wrappers-sumo@0.8.4:
version "0.8.4"
resolved "https://registry.yarnpkg.com/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.4.tgz#6656a3e7e0551ecce08ddee4bfb501a092eac6fa"
integrity sha512-ql7hcgulKZ3ekfa2DGAogcCKsWU0diA/0nArz1CFzh93WQdb46/Kj18ka/Hifq6uA3Ush34Pc6vU/6HXeRwUkg==