Compare commits

Author SHA1 Message Date
sneak 8bc542f1e5 README: say which records quak could not decrypt lib.backup() reports
check / check (push) Waiting to run
It reports each such album, and each such file in its scope that has no
copy that decrypted, not every record the library left out.

Model: opus-5-5
2026-10-14 01:41:38 +00:00
sneak c465496682 Back up from a copy that decrypted even outside a run's albums; README wording
A file whose copy in an album in scope does not decrypt is now backed up
from a copy that decrypted in any album, not only one in scope; with no
such copy it is still recorded as failed. The README now says the exit
code stays at 1 until the server sends a copy that decrypts or deletes it,
and that only a run without --json prints the FAILED album line.
StoredMetadata.undecryptable is required, like the other fields.

Model: opus-5-5
2026-10-14 01:41:38 +00:00
sneak 0a756be5de Test: a backup limited to one album stores a file whose copy there does not decrypt
A run of lib.backup() limited to "Trip" must store a file whose copy in
Trip does not decrypt when its copy in an album the run skips does. Today
the run neither stores it nor records it as failed.

Model: opus-5-5
2026-10-14 01:41:38 +00:00
sneak a0a97f1019 One album or file quak cannot decrypt no longer stops a refresh or a backup (closes #181)
collectionsSince and filesSince put a row that does not decrypt or parse in
an undecryptable list with the reason instead of throwing. The refresh
leaves it out, an album with its files, and the store keeps the list in
metadata.json until the server sends a copy that decrypts or deletes it,
since the next refresh asks only for changes. lib.backup() records each
such file in failures.json as "could not be decrypted: <reason>", unless a
copy in another album decrypted, logs one line per such album and lists
their IDs in undecryptableAlbums; quak backup exits 1 while any remain.
Tests use a stand-in Ente server with real encryption.

Model: opus-5-5
2026-10-14 01:41:38 +00:00
clawbot 54133cfa5f Test quak backup end to end against a fake Ente server (closes #186)
check / check (push) Waiting to run
test/cli/backup-end-to-end.test.ts runs backupCommand through run, as
bin/quak.ts does, with the real loadSession reading a session saved after
logging in, and every request answered by a fake of the Ente HTTP API serving
data encrypted with libsodium. It covers the backup tree of an account with
two albums of its own and one shared with it, a second run that downloads
nothing, a download that succeeds after two 503s, a 404 recorded in
failures.json with exit 1, a refresh answered 401 with exit 3, and --verify
putting back an original changed on disk.

The fake server and the account it logs in moved from the backup-metadata
test into test/ente-server.ts, unchanged, so both tests use them.

Model: opus-5-5
2026-10-14 03:38:38 +02:00
clawbot ab28d86aaa An ID on the command line is decimal digits only (closes #185)
check / check (push) Waiting to run
files --collection, get, get-thumb and helper fix-missing-thumbnails
--file read an ID with Number(), so 0x10 meant 16, 1e3 meant 1000 and
an empty string meant 0, and --file dropped an ID it could not read.
parseID in src/cli-commands.ts now takes an ID only when it is decimal
digits and a safe integer; anything else prints "Invalid file ID: ARG"
or "Invalid collection ID: ARG" and the command exits 1. The fixer
checks every --file ID before it opens the library, so one bad ID fails
the whole command before any request.

Model: opus-5-5
2026-10-14 03:06:35 +02:00
clawbot 2402ec97f7 README: running quak backup from cron, and its exit codes (closes #170)
check / check (push) Successful in 3m59s
A new README section shows how to run quak backup unattended: log in once
as the job's user, a crontab with a backup every night and --verify on
Sundays, output appended to a log file, and the HOME and XDG_DATA_HOME the
job needs to find the saved session. A table gives each exit code as the
code returns it, and says which failures the next run retries. The
introduction now lists everything the backup keeps for each file. It, the
backup layout tree and the lib.backup() entry say EXIF and XMP are kept for
an image only, and dimensions for a JPEG only.

Judgement call: the --verify run takes Sunday's slot rather than a second
job that night, since an overlapping run would exit 2.

Model: opus-5-5
2026-10-07 00:47:37 +02:00
clawbot ddf58af3cc quak backup --verify re-hashes stored originals and downloads again any that do not match (closes #168)
check / check (push) Failing after 1m22s
`--verify`, or `lib.backup({ verify: true })`, hashes each original already
at its save path as the download check does, streamed, a live photo as
`<imageHash>:<videoHash>`. A mismatch is logged, removed and put back in the
same run, and what is put back is hashed too, since a copy from the content
cache is not checked; one that still does not match, or a failed fetch, goes
into `failures.json`. A file with no recorded hash counts as unchecked. The
result, the summary and `--json` gain `verified`, `mismatched` and
`unchecked`.

Judgement call: a stored original that cannot be read for hashing is recorded as failed and left in place.
Judgement call: a copy put back that still does not match stays at its save path and is not counted as downloaded.
Judgement call: the summary prints the three counts only with `--verify`.

Model: opus-5-5
2026-10-06 22:47:26 +02:00
clawbot bd77422965 quak backup refuses to run while another backup of the same directory runs, exit 2 (closes #169)
check / check (push) Successful in 3m10s
lib.backup() takes a lock, backup.lock in its download directory, made
with proper-lockfile, before its refresh, and removes it when it ends. A
second backup of the directory fails at once with an error naming it.
quak backup takes the lock itself before it opens its library and passes
lockHeld to the backup, so a refused run sends no request; it prints the
error as one line and exits 2. A lock untouched for 10 seconds, left by a
run that could not remove it, is taken over.

Deviation: yarn.lock was regenerated by yarn add in the pinned node image.
Judgement call: a run failing at its refresh leaves the directory, empty.
Judgement call: a lock removed mid-run stops that run with an uncaught
error, the library's default.

Model: opus-5-5
2026-10-06 20:30:51 +02:00
clawbot 31b50a211d quak backup writes each original's EXIF, XMP and dimensions into its JSON (closes #167)
check / check (push) Successful in 3m18s
Each file's JSON gains imageMetadata, what extractImageMetadata finds in
the stored original (for a live photo, its image), or the reason the
read failed in imageMetadataError; a failed read fails neither the file
nor the run. A video is not read. An original is read when the run
stores it or when its JSON has neither field; otherwise the field is
carried over from that JSON, so a run does not read every original
again. The hand-built JPEG fixtures move to test/exif-jpeg.ts so the
backup tests can use them.

Judgement call: an original with nothing to record gets imageMetadata
{} instead of no field, so it is not read again on every run.

Model: opus-5-5
2026-10-06 16:47:31 +02:00
clawbot f6317109bc An expired session exits 3 with one line saying to run quak login (closes #164)
check / check (push) Successful in 3m8s
A 401 from the server that ends a command reaches run in src/cli-run.ts as
an ApiError; run prints one line saying to run "quak login" and exits 3.
quak backup meets it on its first refresh, before it touches any file. For
the commands that load the saved session, a missing or corrupt session file
keeps its message and also exits 3.

Judgement call: quak logout is unchanged; it handles its own errors and
deletes the session file whatever the server answers.
Judgement call: quak backup still prints its two progress lines before the
error line.

Model: opus-5-5
2026-10-06 14:01:48 +02:00
clawbot ad0e11407f quak backup retries failed requests for longer (closes #165)
check / check (push) Successful in 4m16s
src/retry.ts exports UNATTENDED_RETRY_OPTIONS beside the unchanged
default: 10 attempts, a 1 s base delay and a 60 s cap, so a request that
keeps failing waits at most 243 s before it gives up. bin/quak.ts loads
the backup's session with them, so its refresh, ML data and downloads
all use them; every other command keeps the default. What is retried
and the backoff formula are unchanged.

Model: opus-5-5
2026-10-06 12:47:40 +02:00
clawbot fb4fff59fe Re-vendor the canonical files from sneak/prompts at dd4027b (closes #171)
check / check (push) Successful in 4m45s
.dockerignore, .gitignore, .prettierignore and REPO_POLICIES.md are
copied from sneak/prompts at dd4027b; .editorconfig, check.yml and
.prettierrc already matched. This repository's build artifacts follow
the copied content in .gitignore and, anchored, in .dockerignore. The
scripts follow the new policy: check runs fmt-check, cibuild bootstraps
and runs check before the image build, fmt and fmt-check find the
pinned yarn under nvm, and the image's last stage marks /app safe for
git. The build-context test expects the new pattern forms.

Judgement call: script/precommit runs lint and fmt-check without the tests, so the README's red-phase test commit can still land; the policy allows that form only where local testing is impossible.
Not applicable: the issue's .golangci.yml entries; this TypeScript repository has none.

Model: opus-5-5
2026-10-06 10:30:29 +02:00
clawbot 2483c85321 quak backup writes the account and album records backup-metadata writes (closes #166)
check / check (push) Successful in 1m23s
quak backup now writes account.json with the account's email and user ID,
adds each album's ownerID, isShared, updationTime and, when present, its
magicMetadata, pubMagicMetadata and sharedMagicMetadata to the album's
JSON, and adds updationTime to each file's JSON. The fields and their order
match backup-metadata's account.json, _collection.json and per-file JSON.
The interface runBackup drives gains whoami, which the library passes
through from its client.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-06 07:13:24 +02:00
clawbot ab4d5d2cc2 quak backup writes each file's ML data into its JSON (closes #163)
check / check (push) Successful in 1m37s
lib.backup() now waits for an ML data fetch, joining the one its refresh
started or starting one, before it writes the per-file JSON, and each
file's JSON carries the cached payload as mlData. When the fetch fails,
each file with no cached ML data gets mlDataError and an entry in
failures.json, so the result counts it as failed and quak backup exits 1;
the next run fetches again.

Judgement call: the wait comes after the originals are downloaded, so the fetch runs alongside the downloads.
Judgement call: a failed ML fetch is recorded per file in failures.json, which is how the exit code goes non-zero without changing src/cli-commands.ts.

Model: opus-5-5
2026-10-06 05:13:27 +02:00
40 changed files with 4026 additions and 665 deletions
+75 -42
View File
@@ -1,53 +1,86 @@
# Mirrors .gitignore, with one deliberate exception: .gitignore itself stays # .dockerignore does NOT use .gitignore semantics. Docker matches with
# in the build context, because prettier 3 reads it as a default ignore file # moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
# and dropping it would change what the lint phase's prettier check sees. # `/` and an unprefixed pattern is anchored at the context root. Every
# depth-independent pattern therefore needs `**/`, or `config/.env` and
# `certs/server.key` still ship while this file reads as solved. Only
# genuinely root-anchored entries go unprefixed. Never transplant these
# into .gitignore, where `**/` is wrong.
# #
# .git is deliberately NOT excluded: the build derives the version it stamps # Matching is case-sensitive, so secrets use character ranges rather
# from it (script/version). It is sent without its config, which holds the # than an ALL-CAPS twin, which would still miss `Server.Key`.
# clone's remote URL and any credential in it, and which the build stage, the #
# final image, would otherwise carry. git describe does not need it. # Extend with this repo's own host-built artifacts, written anchored:
.git/config # `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
# deletes the package directory from the context.
# OS # .git is sent without its config. Without a VERSION build argument the
.DS_Store # stage that compiles runs `git describe --tags --always` on .git, which
Thumbs.db # does not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there.
# Each submodule keeps a config with the same exposure in its git directory
# under .git/modules/, nested again for a submodule's own submodules, or in
# its own .git directory when it keeps one.
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
# `deploy/config`, `config/lib`) loses its whole git directory, because
# `**/.git/modules/**/config` also matches that segment's directory
# under .git/modules/. Go's version stamping then fails the build;
# nothing leaks. Name such a submodule without that segment:
# `git submodule add --name`.
**/.git/config
**/.git/modules/**/config
# Editors # Agent scratch: one full checkout of the repo per in-flight agent.
*.swp # Anchored because it occurs once where agents run at the repo root.
*.swo # KNOWN GAP: a repo running agents in subdirectories still ships
*~ # `services/api/.claude/` and must add its own anchored entry.
*.bak .claude
.idea/
.vscode/
*.sublime-*
# Node # Environment files. `*.env` covers bare `.env` and the `prod.env`
node_modules # convention. Re-include a committed template with a negation if the
# build needs one: `!docs/example.env`.
**/*.[eE][nN][vV]
**/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC]
# TypeScript / build artifacts # Private keys and the bundles carrying them. Public certificates
dist # (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
build **/*.[pP][eE][mM]
*.tsbuildinfo **/*.[kK][eE][yY]
coverage **/*.[pP]12
.nyc_output/ **/*.[pP][fF][xX]
**/[iI][dD]_[rR][sS][aA]
**/[iI][dD]_[dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
**/[iI][dD]_[eE][dD]25519
**/[iI][dD]_[eE][dD]25519_[sS][kK]
# Vitest # Dependencies: restored inside the image, never copied in.
.vitest-cache/ **/node_modules
# Environment / secrets # OS metadata.
.env **/.DS_Store
.env.* **/Thumbs.db
*.pem
*.key # Editor state: never a build input, and it churns COPY.
**/*.swp
**/*.swo
**/*~
**/*.bak
**/.idea
**/.vscode
**/*.sublime-*
# TypeScript / build artifacts: the image compiles its own.
/dist
/build
/*.tsbuildinfo
/coverage
/.nyc_output
/.vitest-cache
# Compiled binary (built by make build-bin); around 100 MB # Compiled binary (built by make build-bin); around 100 MB
bin/quak /bin/quak
# quak runtime data (in case anyone runs the CLI from inside the repo) # quak runtime data (in case anyone runs the CLI from inside the repo)
.quak/ /.quak
# Local per-developer tool state, including agent worktrees. Correctness,
# not context size: a worktree copied in here has its own test/ tree, which
# vitest globs alongside the real one, so the containerised suite runs N+1
# times over and still reports success.
.claude/
+32 -10
View File
@@ -11,9 +11,41 @@ Thumbs.db
.vscode/ .vscode/
*.sublime-* *.sublime-*
# Agent scratch (worktrees of this repo, created and destroyed by
# in-flight tooling). Unanchored: .gitignore patterns already match at
# every depth, so no prefix is wanted here. This is not a .dockerignore
# entry and must not be given a `**/` prefix on the way into one.
.claude/
# Node # Node
node_modules/ node_modules/
# Secrets. Unanchored like every entry above, so each matches at every
# depth. Matching is case-sensitive on Linux, so names use character
# ranges rather than a lowercase form that misses `Server.Key`.
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Only the templates `example.env` and `sample.env` are
# re-included below. A repository that commits any other template adds
# its own negation after these lines, for example `!.env.example`.
*.[eE][nN][vV]
.[eE][nN][vV].*
.[eE][nN][vV][rR][cC]
!example.env
!sample.env
# Private keys and the bundles carrying them.
*.[pP][eE][mM]
*.[kK][eE][yY]
*.[pP]12
*.[pP][fF][xX]
[iI][dD]_[rR][sS][aA]
[iI][dD]_[dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
[iI][dD]_[eE][dD]25519
[iI][dD]_[eE][dD]25519_[sS][kK]
# TypeScript / build artifacts # TypeScript / build artifacts
dist/ dist/
build/ build/
@@ -24,18 +56,8 @@ coverage/
# Vitest # Vitest
.vitest-cache/ .vitest-cache/
# Environment / secrets
.env
.env.*
*.pem
*.key
# Compiled binary (built by make build-bin) # Compiled binary (built by make build-bin)
bin/quak bin/quak
# quak runtime data (in case anyone runs the CLI from inside the repo) # quak runtime data (in case anyone runs the CLI from inside the repo)
.quak/ .quak/
# Local per-developer tool settings and scratch state, including the
# worktrees agents check out under this directory
.claude/
-3
View File
@@ -1,5 +1,2 @@
node_modules/ node_modules/
yarn.lock yarn.lock
dist/
build/
coverage/
+2
View File
@@ -58,6 +58,8 @@ COPY --from=test /app/package.json /dev/null
COPY script/ script/ COPY script/ script/
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN script/bootstrap RUN script/bootstrap
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /app
COPY . . COPY . .
+247 -63
View File
@@ -10,9 +10,12 @@ account into a deduplicated local directory tree, skipping files that already
exist on disk and continuing past individual download failures instead of exist on disk and continuing past individual download failures instead of
crashing. For each file it persists the basic metadata fields quak keeps (title, crashing. For each file it persists the basic metadata fields quak keeps (title,
file type, creation and modification time, latitude, longitude, content hash), file type, creation and modification time, latitude, longitude, content hash),
and the private and public magic metadata in full. A helper subcommand can its update time, the private and public magic metadata in full, Ente's ML data
detect and regenerate missing thumbnails, encrypting and uploading them back to for it when there is any, and, for an image (for a live photo, its image), its
the server. original's EXIF and XMP, with its dimensions for a JPEG only; for a video it
keeps none of these three. It runs unattended from cron (see "Running the backup
from cron"). A helper subcommand can detect and regenerate missing thumbnails,
encrypting and uploading them back to the server.
## Getting Started ## Getting Started
@@ -80,6 +83,66 @@ await lib.close();
The lower-level `Client` (login, session serialization, and the raw The lower-level `Client` (login, session serialization, and the raw
enumeration/download calls) is exported too and documented under Design below. enumeration/download calls) is exported too and documented under Design below.
## Running the backup from cron
`quak backup` never prompts, so cron can run it. `make install` builds quak as a
single binary and copies it to `~/bin/quak`. Log in once with it, as the user
the cron job will run as; the session is saved in that user's data directory
(see "Session handling"):
```bash
~/bin/quak login
```
Then add the backup to that user's crontab with `crontab -e`. These two lines
back up the account to `~/photos-backup` at 03:30 every night, with `--verify`
on Sundays, and append all output to `~/quak-backup.log`:
```
30 3 * * 1-6 $HOME/bin/quak backup $HOME/photos-backup >> $HOME/quak-backup.log 2>&1
30 3 * * 0 $HOME/bin/quak backup --verify $HOME/photos-backup >> $HOME/quak-backup.log 2>&1
```
A run with `--verify` does all a plain run does, and also checks each original
already in the backup against the content hash Ente records for it, and replaces
any that do not match (see "Backup layout"). Sunday's run is the `--verify` one,
not a second job that night, because a backup that starts while another backup
of the same directory is running exits 2 without backing anything up.
Cron runs the job with a short `PATH`, usually `/usr/bin:/bin`, so the crontab
names quak by its full path. To find the saved session, quak needs the same
`HOME` as when you logged in, which cron sets from the password file, and on
Linux the same `XDG_DATA_HOME`: quak looks for the session in
`$XDG_DATA_HOME/quak`, or in `~/.local/share/quak` when that is not set. Cron
does not set `XDG_DATA_HOME`, so if your login shell does, set it at the top of
the crontab too. Cron does not expand variables in such a line, so give the full
path:
```
XDG_DATA_HOME=/home/you/.data
```
On macOS the session is in `~/Library/Application Support/quak`, and only `HOME`
matters.
### Exit codes
| Code | Meaning |
| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0` | The backup is complete: every original is at its save path, and no file failed. |
| `1` | The run finished with files in `failures.json` or an album it could not decrypt, or it stopped on another error, printed as one line `quak: <message>`. The next run tries again. |
| `2` | Another backup of the same directory is running. This run sent no request and changed nothing. |
| `3` | There is no usable session: none is saved, the saved one is corrupt, or the server no longer accepts it. Run `quak login` as the user the cron job runs as. |
After a `1`, the next run fetches each missing original again, fetches the ML
data that is not cached, and rebuilds the album links. A file or album quak
could not decrypt keeps the exit code at `1` until the server sends a copy that
decrypts, or deletes it (see "Backup layout"). An original that a `--verify` run
could not read, or put back with bytes that still do not match, stays at its
save path. Only a later `--verify` run checks it again: a run without `--verify`
leaves it as it is and takes the file out of `failures.json`, so that run can
exit 0.
## Examples ## Examples
`examples/download-albums.ts` downloads every album's photos and their metadata `examples/download-albums.ts` downloads every album's photos and their metadata
@@ -137,16 +200,15 @@ alpine. We provide:
- `script/lint` — run eslint and a prettier check, by building the `lint` phase - `script/lint` — run eslint and a prettier check, by building the `lint` phase
of the `Dockerfile`; requires docker (see Linting and testing below) of the `Dockerfile`; requires docker (see Linting and testing below)
- `script/fmt` — format all files with prettier (writes) - `script/fmt` — format all files with prettier (writes)
- `script/fmt-check` — check formatting on the host (read-only); standalone, and - `script/fmt-check` — check formatting on the host (read-only)
not called by `script/check` or `script/precommit`, because `script/lint` - `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
already checks formatting in the container extension)
- `script/check` — run all checks: `test`, `lint` (our own extension)
- `script/docker` — build the image, tagged via `script/projectname` - `script/docker` — build the image, tagged via `script/projectname`
- `script/cibuild` — build the image (what CI runs); its last stage depends on - `script/cibuild` — what CI runs: `script/bootstrap`, then `script/check`, then
the `lint` and `test` phases, so this one build lints, tests and compiles the image build
- `script/precommit` — run by the git pre-commit hook (our own extension); runs - `script/precommit` — run by the git pre-commit hook (our own extension); runs
`script/lint`, which checks both lint and formatting, but deliberately not the `script/lint` and `script/fmt-check` but deliberately not the tests, so the
tests, so the TDD red-phase commit can land TDD red-phase commit can land
- `script/install-precommit` — installs the git pre-commit hook (our own - `script/install-precommit` — installs the git pre-commit hook (our own
extension); `make hooks` shims to it extension); `make hooks` shims to it
@@ -162,22 +224,19 @@ script runs the tools on the host: docker is required, and that also works where
the docker daemon is remote and bind mounts are impossible. the docker daemon is remote and bind mounts are impossible.
The last stage of the `Dockerfile` compiles the package, and it copies a file The last stage of the `Dockerfile` compiles the package, and it copies a file
from each phase, so it cannot be built unless lint and the tests pass. That is from each phase, so it cannot be built unless lint and the tests pass. The image
why `script/cibuild` is a single `docker build`: it runs lint and the tests once build in `script/cibuild` therefore runs lint and the tests a second time, after
each and then compiles. `script/check` has run them.
Every `docker build` in `script/` passes `--no-cache`. On an unchanged tree Every `docker build` in `script/` passes `--no-cache`. On an unchanged tree
Docker would otherwise serve the lint and test steps from cache, nothing would Docker would otherwise serve the lint and test steps from cache, nothing would
run, and the build would still exit 0. run, and the build would still exit 0.
The formatting check is part of the `lint` phase, not a step beside it, so `script/fmt-check` runs prettier on the host. Its verdict matches the `lint`
`script/check` and `script/precommit` do not call `script/fmt-check` as well; phase's: prettier is pinned to an exact version, installed from `yarn.lock`
that would run prettier a second time over the same tree for the same verdict. under `--frozen-lockfile` in both places, and reads `.gitignore` as its default
`script/fmt-check` remains as a standalone entrypoint for asking the formatting ignore file — which is why `.dockerignore` keeps `.gitignore` in the build
question on the host. Its verdict matches the container's: prettier is pinned to context.
an exact version, installed from `yarn.lock` under `--frozen-lockfile` in both
places, and reads `.gitignore` as its default ignore file — which is why
`.dockerignore` keeps `.gitignore` in the build context.
### Version ### Version
@@ -259,11 +318,10 @@ All work on quak is test-driven. No exceptions.
history must still show tests landing before (or with) the matching history must still show tests landing before (or with) the matching
implementation. implementation.
8. The pre-commit hook installed by `make hooks` runs `script/precommit`, which 8. The pre-commit hook installed by `make hooks` runs `script/precommit`, which
runs `script/lint` — eslint and the prettier check, in the container — but runs `script/lint` and `script/fmt-check` but not the tests, and so not the
not the tests, and so not the full `make check`. This is deliberate so the full `make check`. This is deliberate so the TDD red-phase commit (failing
TDD red-phase commit (failing tests, no implementation yet) can land. The tests, no implementation yet) can land. CI executes `script/cibuild`, which
`test` phase is part of the image build, which is what CI executes via runs the tests, so a red branch still cannot reach `next`.
`script/cibuild`, so a red branch still cannot reach `next`.
## Design ## Design
@@ -420,18 +478,24 @@ Backoff is exponential with full jitter: the delay before retry _n_ is
`random() * min(maxDelayMs, baseDelayMs * 2 ** (n - 1))`. The exponential term `random() * min(maxDelayMs, baseDelayMs * 2 ** (n - 1))`. The exponential term
is the ceiling and the wait is drawn below it, so a client that lost many is the ceiling and the wait is drawn below it, so a client that lost many
parallel downloads to one CDN blip does not send them all again at the same parallel downloads to one CDN blip does not send them all again at the same
instant. Defaults, configurable through `ApiClientOptions.retry`: instant. The numbers, configurable through `ApiClientOptions.retry`:
| Option | Default | Meaning | | Option | Default | `quak backup` | Meaning |
| ------------- | ------- | ----------------------------------- | | ------------- | ------- | ------------- | ----------------------------------- |
| `attempts` | `4` | total calls, not retries | | `attempts` | `4` | `10` | total calls, not retries |
| `baseDelayMs` | `500` | ceiling for the first retry's delay | | `baseDelayMs` | `500` | `1000` | ceiling for the first retry's delay |
| `maxDelayMs` | `10000` | upper bound on that ceiling | | `maxDelayMs` | `10000` | `60000` | upper bound on that ceiling |
With those defaults a file that is going to fail gives up after at most three With the defaults a file that is going to fail gives up after at most three and
and a half seconds of waiting. `sleep` and `random` are injectable through the a half seconds of waiting. `quak backup` usually runs from cron with nobody
same option, which is how the test suite exercises the whole policy without watching, so every request it makes uses the `quak backup` column instead,
waiting. exported as `UNATTENDED_RETRY_OPTIONS`: a request that keeps failing gives up
after at most 243 seconds of waiting, and usually after about half that, since
each wait is drawn at random below its ceiling. Every other command uses the
defaults. A library user gets the same budget by passing
`UNATTENDED_RETRY_OPTIONS` as `ApiClientOptions.retry`. `sleep` and `random` are
injectable through the same option, which is how the test suite exercises the
whole policy without waiting.
Two deadlines, renewed for each attempt: Two deadlines, renewed for each attempt:
@@ -516,8 +580,17 @@ The CLI stores the snapshot at the platform-appropriate data directory via
`0600`. The key material is stored in cleartext in the JSON; treat this file as `0600`. The key material is stored in cleartext in the JSON; treat this file as
you would treat the password itself. A missing file is reported as "not logged 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 in"; a file that exists but is corrupt is reported as such, naming the bad
field. Both exit with status 1, except that `quak logout` with no file says field. When the refresh a command starts with gets HTTP 401 from the server,
there is no session and exits 0. because it no longer accepts the saved session's token, the command prints one
line, `quak: the saved session is no longer valid; run "quak login"`, with no
stack trace. All three exit with status 3, which means the user must run
`quak login` again. `quak logout` is the exception: with no file it says there
is no session and exits 0, and it handles a corrupt file or a failed server call
as described below. `quak backup` meets an expired session on the refresh that
starts every run, before it touches any file but its lock (see "Backup layout").
A session that stops working partway through a backup instead fails each
remaining file into `failures.json`, so that run exits 1 and the next one stops
at its refresh with status 3. No command but `quak login` ever prompts.
`quak logout` ends the session on the server, so the token in `session.json` `quak logout` ends the session on the server, so the token in `session.json`
stops working even in a copy of the file, and then deletes the file. If the stops working even in a copy of the file, and then deletes the file. If the
@@ -539,7 +612,7 @@ quak collections [--json] list all collections
quak files --collection <id> [--json] list files in a collection quak files --collection <id> [--json] list files in a collection
quak get <fileID> [--out path] [--collection] download and decrypt a file quak get <fileID> [--out path] [--collection] download and decrypt a file
quak get-thumb <fileID> [--out] [--collection] download and decrypt a thumbnail quak get-thumb <fileID> [--out] [--collection] download and decrypt a thumbnail
quak backup <dir> [--json] full incremental backup quak backup <dir> [--json] [--verify] full incremental backup
quak backup-metadata <dir> [--exif] dump the metadata quak keeps as JSON quak backup-metadata <dir> [--exif] dump the metadata quak keeps as JSON
quak helper list-missing-thumbnails [--json] find files with missing thumbnails quak helper list-missing-thumbnails [--json] find files with missing thumbnails
quak helper fix-missing-thumbnails [--file ids] [--json] generate + upload missing thumbnails quak helper fix-missing-thumbnails [--file ids] [--json] generate + upload missing thumbnails
@@ -551,8 +624,18 @@ library. The read commands — `collections`, `files`, `get`, `get-thumb`,
`helper fix-missing-thumbnails` — force a fresh server round-trip before they `helper fix-missing-thumbnails` — force a fresh server round-trip before they
answer, so they report current account state rather than whatever the cache last answer, so they report current account state rather than whatever the cache last
held. If that round-trip fails, the command prints the error on one line and held. If that round-trip fails, the command prints the error on one line and
exits 1. `--cache-dir` overrides where the cache lives; without it each account exits 1, or 3 when the server no longer accepts the saved session (see "Session
gets its own directory under the per-user cache path. handling"). An album or file quak cannot decrypt does not fail it; the command
answers without it (see "Default reads vs. fresh reads"). `--cache-dir`
overrides where the cache lives; without it each account gets its own directory
under the per-user cache path.
An ID given to `files --collection`, `get`, `get-thumb` or
`helper fix-missing-thumbnails --file` is written in decimal digits, no larger
than 9007199254740991. Anything else, such as `0x10`, `1e3` or `-1`, is refused
before any request with one line, `Invalid file ID: <arg>` or
`Invalid collection ID: <arg>`, and exit status 1. One bad `--file` ID refuses
the whole command, so nothing is fixed.
`get` and `get-thumb` resolve the file by ID directly, so `--collection` is `get` and `get-thumb` resolve the file by ID directly, so `--collection` is
accepted for backward compatibility but ignored. For a live photo, `get` writes accepted for backward compatibility but ignored. For a live photo, `get` writes
@@ -571,6 +654,10 @@ reads no tag from is recorded, base64, as `exifRaw`, with the reason in
`exifError`. `collections`, `files`, `backup`, `helper list-missing-thumbnails` `exifError`. `collections`, `files`, `backup`, `helper list-missing-thumbnails`
and `helper fix-missing-thumbnails` take `--json` for machine-readable output. and `helper fix-missing-thumbnails` take `--json` for machine-readable output.
`backup --verify` also hashes the originals already in the backup and replaces
any that do not match the content hash Ente records; one it cannot replace goes
into `failures.json` (see "Backup layout").
`backup-metadata` fetches ML data in requests of up to 200 files. When a request `backup-metadata` fetches ML data in requests of up to 200 files. When a request
fails, the error is logged, each of its files is written with the reason in an fails, the error is logged, each of its files is written with the reason in an
`mlDataError` field instead of `mlData`, and the dump goes on. The exit code is `mlDataError` field instead of `mlData`, and the dump goes on. The exit code is
@@ -599,8 +686,12 @@ the smallest does not.
per unique file, two for a live photo: see per unique file, two for a live photo: see
below) below)
YYYY-MM-DD.<fileID>.json the file's basic metadata fields quak YYYY-MM-DD.<fileID>.json the file's basic metadata fields quak
keeps, and its private and public magic keeps, its update time, its private and
metadata public magic metadata, its ML data, and,
for an image (for a live photo, its
image), its original's EXIF and XMP, with
dimensions for a JPEG only; none of these
three for a video
YYYY-MM-DD.<fileID>.livephoto.json YYYY-MM-DD.<fileID>.livephoto.json
which of a live photo's two files is which which of a live photo's two files is which
collections/ collections/
@@ -608,6 +699,8 @@ the smallest does not.
<title> -> ../../YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext> <title> -> ../../YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>
(symlink) (symlink)
<name>.json collection metadata + file list <name>.json collection metadata + file list
account.json the account's email and user ID
backup.lock the lock a running backup holds (see below)
failures.json files that failed and have not yet succeeded failures.json files that failed and have not yet succeeded
``` ```
@@ -619,12 +712,67 @@ name as uploaded, case kept, or `.bin` when it has none or it holds anything but
letters and digits. When the date or the time zone changes, the next run saves letters and digits. When the date or the time zone changes, the next run saves
the original at its new path and leaves the old copy where it is. the original at its new path and leaves the old copy where it is.
`account.json` holds the account's `email` and `userID`, as `backup-metadata`
writes it. A collection's JSON holds its `id`, `name`, `type`, `ownerID`,
`isShared` and `updationTime`, its `magicMetadata`, `pubMagicMetadata` and
`sharedMagicMetadata` when it has them, as `backup-metadata`'s
`_collection.json` does, and `files`, each file's `id` and `metadata`. A file's
JSON holds its `id`, `collectionID`, `ownerID`, `metadata` and `updationTime`,
and its `magicMetadata` and `pubMagicMetadata` when it has them. Update times
are in microseconds, as Ente records them.
A file's JSON holds Ente's ML data for it (its faces and its CLIP embedding) as
`mlData`, the same payload `backup-metadata` writes; a file Ente has no ML data
for has no `mlData`. The backup waits for the library's ML data fetch to finish
before it writes the JSON files. If that fetch fails, each file whose ML data is
not in the cache gets the reason in `mlDataError` instead and counts as failed,
and the next run fetches it again. The JSON files are rewritten on every run, so
ML data that arrived since the last run appears.
A file's JSON holds, as `imageMetadata`, what `backup-metadata --exif` records
from its original, or from a live photo's image: `format`, `width` and `height`
for a JPEG, `exif` (or `exifRaw` and `exifError`), and `xmp`. An original with
none of these gets `{}`. A video gets no `imageMetadata`: reading a whole video
to look for tags is not worth it. When the original cannot be read, the reason
is in `imageMetadataError` instead; neither the file nor the run fails. The
original is read when the run stores it, or when the JSON beside it has neither
field, as one an earlier version wrote. Otherwise the field is taken from that
JSON when it is rewritten, so a run does not read every stored original again.
`failures.json` records each failed file with the kind of failure, how many `failures.json` records each failed file with the kind of failure, how many
times it has been tried and when it was last tried. A file leaves it once it times it has been tried and when it was last tried. A file leaves it once it
succeeds, or once it is no longer in the library or in the backup's scope. The succeeds, or once it is no longer in the library or in the backup's scope. The
library's `lib.backup({ includeThumbnails: true })` also writes library's `lib.backup({ includeThumbnails: true })` also writes
`thumbnails/<fileID>.jpg` beside `collections/`; `quak backup` does not. `thumbnails/<fileID>.jpg` beside `collections/`; `quak backup` does not.
A file the library could not decrypt (see "Default reads vs. fresh reads") goes
into `failures.json` with an empty title, since its title is encrypted too, and
an error that starts `could not be decrypted:` and gives the reason. A file in
two albums is backed up as usual if its copy in the other album decrypted. An
album the library could not decrypt has no name quak can read and no files it
can list. A run without `--json` prints one line for it,
`FAILED album <id>: could not be decrypted: <reason>`, and `--json` and the
`BackupResult` list its ID in `undecryptableAlbums`. Every other file is backed
up. Either kind keeps the exit code at 1, run after run, until the server sends
a copy that decrypts or deletes it; the next run then drops it.
`backup.lock` keeps two backups of the same directory from running at once, such
as a cron run that starts while the previous one is still going. A backup
creates `<dir>` if it is missing and takes the lock before its refresh, and
removes the lock when it ends, whether it succeeds or fails; `quak backup` takes
it before it opens its library, so a refused run sends no request. The lock is a
directory that
[proper-lockfile](https://github.com/moxystudio/node-proper-lockfile) creates
and keeps touching while the backup runs. A second backup of the directory, from
another process or the same one, fails at once: `quak backup` prints
`quak: another backup of <dir> is running` and exits with status 2. A run
stopped with Ctrl-C or `kill` removes the lock as it exits. Only a run that
cannot, such as one killed with SIGKILL or cut off by a crash or power loss,
leaves it behind; once it has gone 10 seconds untouched, the next run takes it
over, so nobody has to remove it. The lock is outside the date folders and
`collections/`, so it is never taken for an original, and the removal of old
album directories never touches it.
A collection's directory and JSON are named after the collection, and a symlink A collection's directory and JSON are named after the collection, and a symlink
after the file's title, both with unsafe characters replaced. When two after the file's title, both with unsafe characters replaced. When two
collections would get the same name, or two symlinks in one collection the same collections would get the same name, or two symlinks in one collection the same
@@ -655,8 +803,26 @@ 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 is logged and the backup continues with the next file. The exit code is non-zero
if any files failed. `quak backup` opens its library with the thumbnail and if any files failed. `quak backup` opens its library with the thumbnail and
originals precache off, so the only file content it fetches is the originals the originals precache off, so the only file content it fetches is the originals the
backup stores. The library's ML data fetch still runs and fills the cache's backup stores.
`mldata/`.
With `--verify`, or `lib.backup({ verify: true })`, a run also hashes each
original already at its save path the way a download is checked (see "On-disk
cache layout" below): its bytes, read in chunks, or a live photo's image and
video, joined as `<imageHash>:<videoHash>`. An original that matches the content
hash its metadata records is left as it is. One that does not is logged on one
line naming the file, deleted (a live photo's image and video both), and put
back in the same run like a missing one: downloaded, or copied from the cache if
the cache holds it. What is put back is hashed too, because a copy from the
cache is not checked as a download is. If it still does not match, it stays at
its save path and the file goes into `failures.json`, as it does when the
download fails. A file whose metadata records no hash is left as it is and
counted as unchecked. A stored original that cannot be read is left as it is and
counts as failed. The summary and `--json` add the counts `verified`,
`mismatched` and `unchecked`, all of originals that were already stored; one
first downloaded in this run is in none of them. A mismatch that was put back
with matching bytes does not make the exit code non-zero. Without `--verify`
nothing is hashed, the summary is unchanged, and the three counts are 0 in
`--json`.
Each original is written to a temporary file in the same directory, synced to Each original is written to a temporary file in the same directory, synced to
disk, and renamed into place, so an original is either complete or absent, even disk, and renamed into place, so an original is either complete or absent, even
@@ -699,7 +865,8 @@ The library's primary surface is the cache-backed `Library`; the lower-level
`Client` sits underneath it and is covered by the Design sections above. The `Client` sits underneath it and is covered by the Design sections above. The
test suite is the canonical, executable documentation — `test/library/` and test suite is the canonical, executable documentation — `test/library/` and
`test/client/usage.test.ts` walk most operations, `test/cli/backup.test.ts` `test/client/usage.test.ts` walk most operations, `test/cli/backup.test.ts`
walks `lib.backup()`, and `yarn test` verifies them. walks `lib.backup()`, `test/cli/backup-end-to-end.test.ts` runs `quak backup`
against a fake of the Ente HTTP API, and `yarn test` verifies them.
### Opening a library ### Opening a library
@@ -762,6 +929,16 @@ rejects the caller (default reads stay silent and keep serving the last good
copy). The CLI's read commands use fresh reads (issue copy). The CLI's read commands use fresh reads (issue
https://git.eeqj.de/sneak/quak/issues/75). https://git.eeqj.de/sneak/quak/issues/75).
An album or file in the server's answer that quak cannot decrypt, or whose
metadata is not JSON, does not fail the refresh. The refresh leaves it out, an
album with all its files, and drops any older copy the cache held, so reads
answer from what did decrypt. The library keeps a list of what it left out, with
the reason, in `metadata.json`: the next refresh asks the server only for what
changed, so it would not see the record again. A record stays on that list until
a later refresh gets a copy that decrypts or learns that the server deleted it.
`lib.backup()` reports each such album, and each such file in its scope that has
no copy that decrypted (see "Backup layout").
### Read surface ### Read surface
- `lib.albums.list()` → `Album[]`, newest-updated first. - `lib.albums.list()` → `Album[]`, newest-updated first.
@@ -873,16 +1050,25 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
`SimilarResult[]` (`{ fileID, score }`, cosine similarity, most similar first, `SimilarResult[]` (`{ fileID, score }`, cosine similarity, most similar first,
default limit 20). quak bundles no text encoder, so `searchByEmbedding` takes default limit 20). quak bundles no text encoder, so `searchByEmbedding` takes
a query vector the caller produced elsewhere. a query vector the caller produced elsewhere.
- `await lib.backup(opts?)` → `BackupResult`. It waits for a refresh as - `await lib.backup(opts?)` → `BackupResult`. It takes the lock in the download
directory, and fails at once with an error whose `code` is `ELOCKED` while
another backup of it runs. A caller can instead take the lock itself, before
it opens its library, as `quak backup` does: `await lockBackupDirectory(dir)`
takes it, failing the same way, and returns the function that releases it, and
the caller passes `lockHeld: true` to the backup. It waits for a refresh as
`fresh()` does, puts every in-scope original not already at its save path `fresh()` does, puts every in-scope original not already at its save path
there as `photo.download()` does (and, with `includeThumbnails`, fetches there as `photo.download()` does (and, with `includeThumbnails`, fetches
thumbnails) through the content cache, and rebuilds the on-disk backup tree thumbnails) through the content cache, waits for an ML data fetch, and
with a durable failure ledger. A fetched original is written straight to its rebuilds the on-disk backup tree with a durable failure ledger. Each file's
save path and not into the cache, which then counts it as present; one the JSON holds its ML data and, for an image (for a live photo, its image), its
cache already held is copied from there. `BackupOptions`: `downloadDirectory` original's EXIF and XMP, with its dimensions for a JPEG only; a video's JSON
(falls back to the library's), `includeOriginals` (default `true`), holds none of these three. A fetched original is written straight to its save
`includeThumbnails` (default `false`), `onlyAlbumNames`, and `onProgress`. See path and not into the cache, which then counts it as present; one the cache
Backup layout above for the tree it writes. already held is copied from there. `BackupOptions`: `downloadDirectory` (falls
back to the library's), `includeOriginals` (default `true`),
`includeThumbnails` (default `false`), `onlyAlbumNames`, `verify` (default
`false`), `onProgress`, and `lockHeld` (default `false`). See Backup layout
above for the tree it writes.
### Request pools ### Request pools
@@ -977,14 +1163,12 @@ documents:
before the implementation. Tests are the canonical API documentation and must before the implementation. Tests are the canonical API documentation and must
be commented thoroughly. `main` and `next` are always green. be commented thoroughly. `main` and `next` are always green.
- **Required checks before every commit:** `make lint` must pass — that is - **Required checks before every commit:** `make lint` and `make fmt-check` must
eslint plus the prettier check, and it builds the `lint` phase of the pass. `make lint` is eslint plus the prettier check, and it builds the `lint`
`Dockerfile`, so it needs docker. The pre-commit hook enforces exactly that. phase of the `Dockerfile`, so it needs docker. The pre-commit hook enforces
`make check` (which also runs the tests) must pass before merging into `next`. exactly that. `make check` (which also runs the tests) must pass before
`make fmt-check` is available for a host-side formatting check on its own, but merging into `next`. Never invoke eslint or prettier directly; linting runs in
it is not a separate requirement: `make lint` already covers it, and running the container only.
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 - **Formatting:** prettier with 4-space indents and `proseWrap: always` for
markdown. Use `make fmt` to format. Use `yarn` not `npm`. markdown. Use `make fmt` to format. Use `yarn` not `npm`.
+120 -44
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-09-08 last_modified: 2026-10-04
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -104,10 +104,14 @@ style conventions are in separate documents:
`lint` phase and a `test` phase, with the final stage depending on both so the `lint` phase and a `test` phase, with the final stage depending on both so the
image cannot be built unless they pass. For non-server repos the final stage image cannot be built unless they pass. For non-server repos the final stage
brings up a development environment; for server repos it is the runtime image. brings up a development environment; for server repos it is the runtime image.
Dockerfiles install development prerequisites by running `script/bootstrap` The gate phases and the build stage start from their pinned base images and
rather than duplicating installs inline; COPY `script/` and the dependency install what those images lack either inline, as the canonical Go `Dockerfile`
manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before below does for `git`, or by running `script/bootstrap`, as the `prompts`
running it. repo's own `Dockerfile` does for its yarn packages. The development
environment stage installs development prerequisites by running
`script/bootstrap` rather than duplicating its installs inline. A stage that
runs `script/bootstrap` COPYs `script/` and the dependency manifests
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is - **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
no separate lint file. `script/lint` and `script/test` each build one phase no separate lint file. `script/lint` and `script/test` each build one phase
@@ -156,11 +160,14 @@ style conventions are in separate documents:
not evidence that anything ran: a sub-second build reporting success is a not evidence that anything ran: a sub-second build reporting success is a
cache hit, not a result. Never invalidate by pruning — `docker builder prune` cache hit, not a result. Never invalidate by pruning — `docker builder prune`
and friends destroy a build cache shared with every other build on the host. and friends destroy a build cache shared with every other build on the host.
When a check is added or changed, prove it works by planting a defect it must
catch and watching the run fail on it, then revert the defect. A green run
alone shows neither that the check ran nor that it covers what it should.
- **The gate phases are separate stages, and the build stage depends on both.** - **The gate phases are separate stages, and the build stage depends on both.**
The lint phase is based on the `golangci/golangci-lint` image (pinned by The lint phase is based on the `golangci/golangci-lint` image (pinned by
hash), so lint failures surface in seconds rather than after a full compile, hash), so lint failures surface in seconds rather than after a full compile,
and the test phase is based on the Go image. The canonical Go repo and the test phase is based on the Debian Go image. The canonical Go repo
`Dockerfile`: `Dockerfile`:
```dockerfile ```dockerfile
@@ -173,8 +180,9 @@ style conventions are in separate documents:
COPY . . COPY . .
RUN golangci-lint run --config .golangci.yml ./... RUN golangci-lint run --config .golangci.yml ./...
# Test phase # Test phase. -race needs cgo and so a C compiler, which the Debian Go
# golang:1.x-alpine, YYYY-MM-DD # image ships and the alpine one does not.
# golang:1.x, YYYY-MM-DD
FROM golang@sha256:... AS test FROM golang@sha256:... AS test
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
@@ -191,15 +199,29 @@ style conventions are in separate documents:
FROM golang@sha256:... AS builder FROM golang@sha256:... AS builder
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
ARG VERSION=dev # The VERSION build arg when one is given, otherwise
RUN CGO_ENABLED=0 go build -trimpath \ # `git describe --tags --always` on the .git in the build context. With
-ldflags="-s -w -X main.Version=${VERSION}" \ # .git present, a version that is still empty, dev or unknown fails the
-o /app ./cmd/app/ # build: git is missing or could not read the checkout.
ARG VERSION
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \
exit 1 ;; \
esac; \
fi; \
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage, and the last one # Runtime stage, and the last one
FROM alpine@sha256:... FROM alpine@sha256:...
@@ -221,10 +243,41 @@ style conventions are in separate documents:
(e.g. a web frontend compiled in a separate stage), the lint phase must (e.g. a web frontend compiled in a separate stage), the lint phase must
create placeholder files so the embed directives resolve. Example: create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
- If the project requires CGO or system libraries for linting (e.g. - If the project requires CGO or system libraries for linting, install them
`vips-dev`), install them in the lint phase with `apk add`. in the lint phase. The `golangci/golangci-lint` image is Debian-based and
- `ARG VERSION=dev` is declared in the stage that compiles and supplied by has no `apk`, so install with `apt-get` under the Debian package name
`script/docker` and `script/cibuild`; no stage may call `git describe`. (`libvips-dev`, where alpine says `vips-dev`), and delete the package
lists in the same `RUN`, so the layer does not keep them:
```dockerfile
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/*
```
- `.dockerignore` lets `.git` into the build context. It keeps out every git
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
repository's own, each submodule's under `.git/modules/`, and that of a
submodule keeping its own `.git` directory. `git describe` does not need
them, and each can hold a credential: a password in a remote URL, or the
token the CI checkout step stores there. A submodule whose name has a
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
git directory to `**/.git/modules/**/config`, and Go's version stamping
then fails the build: give it a name without that segment
(`git submodule add --name`). The stage that compiles has `git` (the
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
takes the version from the `VERSION` build argument when one is given,
otherwise from `git describe --tags --always`. That gives the tag on a
tagged commit; on a later commit, the tag, the number of commits since it
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
tag is reachable. The stage that compiles also marks its working directory
safe for git (`git config --system --add safe.directory /src`): a context
sent as a tar stream keeps the sender's file owners, and git refuses a
checkout owned by another user, so the version would come out empty.
`ARG VERSION` has no default, and the build fails if the context carries
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` on push, and checks out the repo as its only other step. runs `script/cibuild` on push, and checks out the repo as its only other step.
@@ -233,7 +286,12 @@ style conventions are in separate documents:
carry the same guarantee, because its gate phases may come from the cache. The carry the same guarantee, because its gate phases may come from the cache. The
image build is uncached and so runs the gate phases a second time. That is the image build is uncached and so runs the gate phases a second time. That is the
price of the rule above, and it is worth paying: the image that ships is built price of the rule above, and it is worth paying: the image that ships is built
from a run of its own gates rather than from a cache entry. from a run of its own gates rather than from a cache entry. A separate
workflow limited to `main` by a `branches` list under `on: push` cannot be
checked by review: to try a change to it, add the feature branch to that list
and push, then remove the branch from the list again before merging. Keep any
job in it that publishes behind `if: github.ref_name == 'main'`, so the run
from the feature branch publishes nothing.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -286,17 +344,19 @@ style conventions are in separate documents:
``` ```
`-count=1` is required on both invocations: it defeats Go's test _result_ `-count=1` is required on both invocations: it defeats Go's test _result_
cache, so the target cannot report a pass it did not earn, and the rerun cache, so neither run can report a stored pass in place of running the
reproduces a failure instead of replaying it. It leaves the build cache tests. It leaves the build cache alone, so it costs the runtime of the suite
alone, so it costs the runtime of the suite and no recompilation. and no recompilation.
Note that this is a second, independent cache, stacked below the Docker That cache is Go's own, separate from Docker's layer cache. Go stores a
layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26) passing result in its cache directory (`GOCACHE`), and when the same tests
addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes; run again on unchanged code it prints that result, marked `(cached)`,
it does not guarantee `go test` inside that step does any work, because the without running them. That matters on a developer's machine, where this
`GOCACHE` baked into earlier image layers survives into the re-executed target runs and the directory lasts from one run to the next. The `test`
step. They are two separate defects requiring two separate fixes, and a fix phase of the `Dockerfile` needs no `-count=1`: its base image holds no
for one must not be recorded as covering the other. result for this repo's tests and nothing before its `go test` step runs a
test, so there is nothing to replay. `--no-cache` (above) is what makes that
step run on an unchanged tree.
Python example: Python example:
@@ -340,7 +400,7 @@ style conventions are in separate documents:
— which is more dangerous than a short file with no secret patterns at all, — which is more dangerous than a short file with no secret patterns at all,
because it reads as solved and stops anyone looking. Give every because it reads as solved and stops anyone looking. Give every
depth-independent pattern the `**/` prefix and leave only genuinely depth-independent pattern the `**/` prefix and leave only genuinely
root-anchored entries unprefixed: `.git`, and the repo's own host-built root-anchored entries unprefixed: `.claude`, and the repo's own host-built
binary, written `/myapp` and never `**/myapp`, which would also match binary, written `/myapp` and never `**/myapp`, which would also match
`cmd/myapp/` and delete the package directory from the context. Matching is `cmd/myapp/` and delete the package directory from the context. Matching is
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
@@ -365,12 +425,13 @@ style conventions are in separate documents:
directory, so a repo running agents in subdirectories still ships directory, so a repo running agents in subdirectories still ships
`services/api/.claude/` and must add its own anchored entry there. `services/api/.claude/` and must add its own anchored entry there.
- **Excluding `.git` means `git describe` cannot run inside any build stage, and - **A plain `docker build .` of a clone stamps the version that
it fails quietly there.** In a build stage there is no repository, so `git describe --tags --always` gives**, derived from the `.git` in the build
`git describe` writes nothing to stdout, `-X main.Version=` comes out empty, context as the canonical `Dockerfile` above shows. Without its failure check,
the binary reports no version at all, and the build still exits 0. Compute the a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
version on the host and thread it in as a build arg. `script/docker` and and the build would still exit 0. `script/docker` and `script/cibuild` pass
`script/cibuild` do this, byte-identically across repos: the version they compute on the host; it takes precedence. They do this
byte-identically across repos:
```sh ```sh
# Own line: a failing command substitution inside an argument does not # Own line: a failing command substitution inside an argument does not
@@ -387,7 +448,7 @@ style conventions are in separate documents:
fallback is applied — a live check that fires on a build from an export with fallback is applied — a live check that fires on a build from an export with
no `.git` and on a repository with no commits yet. Do not fold it into the no `.git` and on a repository with no commits yet. Do not fold it into the
substitution as `|| echo unknown`, which makes the guard unreachable. The substitution as `|| echo unknown`, which makes the guard unreachable. The
Dockerfile's side is `ARG VERSION=dev` in the stage that compiles, declared Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
the scripts stay byte-identical. One consequence for CI: the standard the scripts stay byte-identical. One consequence for CI: the standard
@@ -426,12 +487,18 @@ style conventions are in separate documents:
`test-support` depguard rule, where a repo names its own test-support packages `test-support` depguard rule, where a repo names its own test-support packages
by full import path. A repo adds entries there and changes nothing else, and a by full import path. A repo adds entries there and changes nothing else, and a
re-vendor carries its entries forward. The canonical golangci-lint version is re-vendor carries its entries forward. The canonical golangci-lint version is
v2.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
image image
(`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, (`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
only pin, since no repo installs golangci-lint on the host: bumping the directive must not name a newer Go minor version than the one golangci-lint
version means changing it and nothing else. was built with, or golangci-lint refuses to lint it: this release lints
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
installs golangci-lint on the host. A repo sets the lint phase digest to the
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
the two prompted the change: the canonical copy can name linters that an older
golangci-lint rejects, and a newer golangci-lint can add linters that
`default: all` switches on until the canonical copy disables them.
- **`script/bootstrap` installs a pinned tool by comparing versions, never by - **`script/bootstrap` installs a pinned tool by comparing versions, never by
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
@@ -455,6 +522,11 @@ style conventions are in separate documents:
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
A Go tool a repo needs on the host is installed with `go install` pinned to
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
a `go.mod` tool dependency or through a `tools.go` file, either of which
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
- When pinning images or packages by hash, add a comment above the reference - When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD). with the version and date (YYYY-MM-DD).
@@ -567,10 +639,10 @@ style conventions are in separate documents:
settings. settings.
- Avoid putting files in the repo root unless necessary. Root should contain - Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`, only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and `Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
language-specific config). Everything else goes in a subdirectory. Canonical and language-specific config). Everything else goes in a subdirectory.
subdirectory names: Canonical subdirectory names:
- `bin/` — executable scripts and tools - `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose - `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
body is a single call into `internal/` or `pkg/`, no project logic in body is a single call into `internal/` or `pkg/`, no project logic in
@@ -601,3 +673,7 @@ style conventions are in separate documents:
- Go: `go.mod`, `go.sum`, `.golangci.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml` - Python: `pyproject.toml`
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
is never committed under a file or directory named after one agent tool, such
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
+108
View File
@@ -25,6 +25,114 @@ declares one.
# Completed Steps # Completed Steps
- 2026-10-13: An album or file quak cannot decrypt, or whose metadata is not
JSON, no longer fails the refresh, so one bad record no longer stops
`quak backup` from storing anything (issue 181). The refresh leaves it out, an
album with its files, and the library keeps it with the reason in
`metadata.json` until the server sends a copy that decrypts or deletes it. The
backup stores every other file, records each such file in `failures.json` with
an error starting `could not be decrypted:`, prints one line naming each such
album's ID, lists those IDs in `undecryptableAlbums`, and exits 1 while any
remain. A file whose copy in another album decrypts is backed up as usual. The
read commands answer from what decrypted.
- 2026-10-14: `test/cli/backup-end-to-end.test.ts` runs `quak backup` as cron
does (issue 186): `backupCommand` and `run`, the real `loadSession` reading a
session saved after logging in, and every request answered by a fake of the
Ente HTTP API serving data encrypted with libsodium. It covers the backup tree
of an account with two albums of its own and one shared with it, a second run
that downloads nothing, a download that succeeds after two 503s, a 404 that
lands in `failures.json` with exit 1, a refresh answered 401 with exit 3, and
`--verify` putting back an original changed on disk. The fake server and the
account it logs in moved from the backup-metadata test into
`test/ente-server.ts`, unchanged.
- 2026-10-14: `files --collection`, `get`, `get-thumb` and
`helper fix-missing-thumbnails --file` take an ID only when it is written in
decimal digits and is a safe integer (issue 185). Before, `Number()` read
`0x10` as 16, `1e3` as 1000 and an empty string as 0, and `--file` dropped an
ID it could not read without a word. Anything else now prints
`Invalid file ID: <arg>` or `Invalid collection ID: <arg>` and exits 1 before
any request; one bad `--file` ID fails the whole command. `parseID` in
`src/cli-commands.ts` does the check for all four.
- 2026-10-06: The README says how to run `quak backup` from cron (issue 170):
log in once as the job's user, a crontab with a backup every night and
`--verify` on Sundays appending to a log file, and the `HOME` and
`XDG_DATA_HOME` the job needs to find the saved session. A table gives each
exit code: 0, the backup is complete; 1, files are in `failures.json` or
another error stopped the run; 2, another backup of the directory is running;
3, there is no usable session. The introduction lists everything the backup
keeps for each file.
- 2026-10-06: `quak backup --verify` and `lib.backup({ verify: true })` hash
each original already at its save path as the download check does, streamed, a
live photo as `<imageHash>:<videoHash>` (issue 168). One that does not match
the content hash its metadata records is logged, removed (both files of a live
photo) and put back in the same run, downloaded or copied from the cache, and
what is put back is hashed too. One that still does not match, or whose
download fails, goes into `failures.json`. One with no recorded hash is left
alone. The result, `--json` and the summary gain `verified`, `mismatched` and
`unchecked`. Without `--verify` nothing is hashed.
- 2026-10-06: Two backups of the same directory never run at once (issue 169).
`lib.backup()` takes a lock, `backup.lock` in its download directory, made
with `proper-lockfile`, before its refresh, and removes it when it ends,
whether it succeeds or fails. A second backup of the directory, from another
process or the same one, fails at once with an error naming the directory.
`quak backup` takes the lock before it opens its library, so a refused run
sends no request; it prints the error as one line and exits 2. A lock that has
gone 10 seconds untouched, left by a run that could not remove it, is taken
over by the next run.
- 2026-10-06: `quak backup` writes each original's EXIF, XMP and dimensions into
the file's JSON as `imageMetadata`, what `backup-metadata --exif` records
(issue 167): for a live photo from its image, for a video nothing, and `{}`
for an original with none of them. A failed read puts the reason in
`imageMetadataError` and fails neither the file nor the run. An original is
read when the run stores it or when its JSON has neither field; otherwise the
field is taken from that JSON. The hand-built JPEGs moved to
`test/exif-jpeg.ts`, beside the HEIC.
- 2026-10-06: When the server answers HTTP 401 and that ends a command that
loads the saved session, because the server no longer accepts its token, the
command prints one line,
`quak: the saved session is no longer valid; run "quak login"`, and exits 3
(issue 164). `quak backup` meets that 401 on the refresh it starts with,
before it touches any file. For the same commands, a missing or corrupt
session file keeps its message and also exits 3, so a cron job can tell that
the user must log in again. `quak logout` is unchanged. `run` in
`src/cli-run.ts` recognises the 401, which reaches it unchanged.
- 2026-10-06: `quak backup` retries a failed request for longer than the other
commands do (issue 165). `src/retry.ts` exports `UNATTENDED_RETRY_OPTIONS`
beside the unchanged default: 10 attempts, a 1 s base delay and a 60 s cap, so
a request that keeps failing waits at most 243 s before it gives up.
`bin/quak.ts` loads the backup's session with them, so its refresh, ML data
and downloads all use them. What is retried and the backoff formula are
unchanged.
- 2026-10-06: The files this repository copies from `sneak/prompts` are copied
again from its commit `dd4027b` (issue 171). `.gitignore` and `.dockerignore`
keep out more secret files, and this repository's build artifacts follow the
copied content. `script/check` runs `script/fmt-check` again, `script/cibuild`
runs `script/bootstrap` and `script/check` before the image build,
`script/fmt` and `script/fmt-check` find the pinned yarn under nvm, and the
image's last stage marks `/app` safe for git.
- 2026-10-06: `quak backup` writes the account and album records
`backup-metadata` writes (issue 166): `account.json` with the account's
`email` and `userID`, and in each album's JSON its `ownerID`, `isShared`,
`updationTime` and, when present, its three layers of magic metadata. Each
file's JSON gains its `updationTime`.
- 2026-10-05: `quak backup` writes each file's ML data (its faces and CLIP
embedding) into the file's JSON as `mlData`, the payload
`lib.mldata.forFile()` returns (issue 163). `lib.backup()` waits for an ML
data fetch before it writes the JSON files. When that fetch fails, each file
whose ML data is not cached gets the reason in `mlDataError` and counts as
failed, and the next run fetches it again.
- 2026-10-03: The download-albums example test no longer fails on a disk with - 2026-10-03: The download-albums example test no longer fails on a disk with
under 50 GiB free (issue 160). It opens its libraries with under 50 GiB free (issue 160). It opens its libraries with
`freeBelowBytes: 0`, so the free space of the disk it runs on cannot shrink `freeBelowBytes: 0`, so the free space of the disk it runs on cannot shrink
+19 -2
View File
@@ -23,6 +23,7 @@ import { run as runCommand } from "../src/cli-run.js";
import { loadSession } from "../src/cli-session.js"; import { loadSession } from "../src/cli-session.js";
import { Client } from "../src/client.js"; import { Client } from "../src/client.js";
import { VERSION } from "../src/index.js"; import { VERSION } from "../src/index.js";
import { UNATTENDED_RETRY_OPTIONS } from "../src/retry.js";
const paths = envPaths("quak", { suffix: "" }); const paths = envPaths("quak", { suffix: "" });
@@ -129,8 +130,24 @@ program
) )
.argument("<dir>", "Output directory") .argument("<dir>", "Output directory")
.option("--json", "Print result as JSON instead of human-readable summary") .option("--json", "Print result as JSON instead of human-readable summary")
.action((dir: string, opts: { json?: boolean }) => .option(
run(backupCommand(context(), dir, opts)), "--verify",
"Re-hash stored originals and download again any that do not match",
)
// A backup usually runs from cron with nobody watching, so every request
// it makes retries for longer than the other commands' requests do.
.action((dir: string, opts: { json?: boolean; verify?: boolean }) =>
run(
backupCommand(
{
...context(),
loadSession: (path) =>
loadSession(path, { retry: UNATTENDED_RETRY_OPTIONS }),
},
dir,
opts,
),
),
); );
const helper = program const helper = program
+3 -1
View File
@@ -36,6 +36,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "9.38.0", "@eslint/js": "9.38.0",
"@types/node": "22.18.13", "@types/node": "22.18.13",
"@types/proper-lockfile": "4.1.4",
"eslint": "9.38.0", "eslint": "9.38.0",
"prettier": "3.8.1", "prettier": "3.8.1",
"typescript": "5.9.3", "typescript": "5.9.3",
@@ -50,6 +51,7 @@
"fast-srp-hap": "2.0.4", "fast-srp-hap": "2.0.4",
"fflate": "0.8.3", "fflate": "0.8.3",
"jpeg-js": "0.4.4", "jpeg-js": "0.4.4",
"libsodium-wrappers-sumo": "0.8.4" "libsodium-wrappers-sumo": "0.8.4",
"proper-lockfile": "4.1.2"
} }
} }
+5 -7
View File
@@ -1,11 +1,8 @@
#!/bin/sh #!/bin/sh
# script/check: run all checks (test, lint). Our own extension to # script/check: run all checks (test, lint, fmt-check). Our own
# scripts-to-rule-them-all. Both are Docker phases. Must not modify any # extension to scripts-to-rule-them-all. test and lint are Docker
# files. # phases; fmt-check is native, because a formatter writes the working
# # tree. Must not modify any files.
# script/fmt-check is not called here, unlike the template: the lint
# phase already runs `prettier --check .`, so calling it would run
# prettier a second time over the same tree for the same verdict.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -13,6 +10,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() { main() {
"$SCRIPT_DIR/test" "$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint" "$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
} }
main "$@" main "$@"
+7 -7
View File
@@ -1,8 +1,7 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. The image's last stage depends on the # script/cibuild: run the CI build. It bootstraps first: a CI runner
# lint and test phases, so this one build runs eslint, prettier and the # checks out and runs this and nothing else, and script/fmt-check runs
# suite once each and then compiles. Unlike the template it does not run # the formatter on the host, which a pristine checkout cannot do.
# script/check first, which would run lint and the tests a second time.
# --no-cache for the same reason as script/docker: the gate phases the # --no-cache for the same reason as script/docker: the gate phases the
# final stage depends on are RUN steps, and a cached one is a check that # final stage depends on are RUN steps, and a cached one is a check that
# did not run. # did not run.
@@ -13,11 +12,12 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/check"
# Own line: a failing command substitution inside an argument does # Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an # not trip `set -e`, so the inline form degrades silently to an
# empty constant. The version resolved here goes in as the VERSION # empty constant. The VERSION build argument takes precedence over
# build arg, which takes precedence over what the build would derive # the version a build stage derives from the .git in the context.
# from the .git in its context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
docker build --no-cache \ docker build --no-cache \
+2 -3
View File
@@ -12,9 +12,8 @@ main() {
cd "$ROOT" cd "$ROOT"
# Own line: a failing command substitution inside an argument does # Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an # not trip `set -e`, so the inline form degrades silently to an
# empty constant. The version resolved here goes in as the VERSION # empty constant. The VERSION build argument takes precedence over
# build arg, which takes precedence over what the build would derive # the version a build stage derives from the .git in the context.
# from the .git in its context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
docker build --no-cache \ docker build --no-cache \
+20 -1
View File
@@ -4,9 +4,28 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
yarn run prettier --write . run_yarn run prettier --write .
} }
main "$@" main "$@"
+20 -1
View File
@@ -4,9 +4,28 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt-check: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
yarn run prettier --check . run_yarn run prettier --check .
} }
main "$@" main "$@"
+5 -5
View File
@@ -2,17 +2,17 @@
# script/precommit: run by the git pre-commit hook; fails the commit if # script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all. # checks fail. Our own extension to scripts-to-rule-them-all.
# #
# Runs lint but deliberately NOT the tests, so the TDD red-phase commit # Runs lint and fmt-check but deliberately NOT the tests, so the TDD
# (failing tests, no implementation yet) can land. CI runs # red-phase commit (failing tests, no implementation yet) can land. CI
# script/cibuild, whose image build includes the test phase, and so # runs script/cibuild, which runs the tests, and so catches any branch
# catches any branch that ships red. The lint phase includes the # that ships red.
# prettier check, so a badly formatted tree still fails the commit.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() { main() {
"$SCRIPT_DIR/lint" "$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
} }
main "$@" main "$@"
+382 -55
View File
@@ -1,18 +1,25 @@
// The backup command, rebuilt on the library API (issue #51). // The backup command, rebuilt on the library API (issue #51).
// //
// `lib.backup()` waits for a completed refresh of the library (a failed one // `lib.backup()` takes the lock in `downloadDirectory` (unless its caller
// fails the backup before any file is touched), then, for every file in scope, // already holds it), and fails at once when another backup of it holds the
// puts its original at its save path under `downloadDirectory`, as // lock. It waits for a completed refresh of the library (a failed one fails the
// `Photo.download()` does, and rebuilds the derived views (per-file sidecars, // backup before any file is touched), then, for every file in scope, puts its
// per-collection symlink trees, per-collection JSON) from the model. The // original at its save path under `downloadDirectory`, as `Photo.download()`
// on-disk layout: // does, waits for an ML data fetch, and rebuilds the derived views (per-file
// sidecars, per-collection symlink trees, per-collection JSON) from the model.
// The on-disk layout:
// //
// <downloadDirectory>/ // <downloadDirectory>/
// YYYY/YYYY-MM/YYYY-MM-DD/ // YYYY/YYYY-MM/YYYY-MM-DD/
// YYYY-MM-DD.<fileID>.<ext> the decrypted bytes (the save path) // YYYY-MM-DD.<fileID>.<ext> the decrypted bytes (the save path)
// YYYY-MM-DD.<fileID>.json per-file metadata sidecar // YYYY-MM-DD.<fileID>.json per-file metadata sidecar, with
// the file's ML data and its
// original's EXIF, XMP and
// dimensions
// collections/<name>/<title> symlink to the original // collections/<name>/<title> symlink to the original
// collections/<name>.json per-collection metadata // collections/<name>.json per-collection metadata
// account.json the account's email and user ID
// backup.lock the lock, while a backup runs
// failures.json durable ledger of unresolved failures // failures.json durable ledger of unresolved failures
// //
// A live photo's original is its image and its video, each with its own // A live photo's original is its image and its video, each with its own
@@ -26,18 +33,32 @@
// no unique state, so they are rebuilt every run; that repairs stale sidecars // no unique state, so they are rebuilt every run; that repairs stale sidecars
// and missing or broken symlinks left by an earlier crash. A rebuild also // and missing or broken symlinks left by an earlier crash. A rebuild also
// removes the symlinks to originals that no longer belong to an album, and the // removes the symlinks to originals that no longer belong to an album, and the
// directories of albums that no longer exist. // directories of albums that no longer exist. The one thing a sidecar takes
// from the sidecar it replaces is its original's EXIF, XMP and dimensions (or
// why they could not be read), so that a run does not read every stored
// original again; a sidecar without them gets them read from the original.
//
// With `verify`, each original already at its save path is hashed as the
// download check hashes it, and one that does not match the content hash its
// metadata records is removed and fetched again in the same run. What is put
// back is hashed too, and recorded as failed if it still does not match.
// //
// Resilience (issue #8): no per-file condition aborts the run. A failed // 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 // download, a failed symlink, or ML data missing because the ML data fetch
// classification, a running attempt count, and the last-tried time, and the run // failed is caught, recorded in `failures.json` with a classification, a
// continues. `result.failed` — and thus the CLI's exit code — stays non-zero // running attempt count, and the last-tried time, and the run continues.
// while any failure remains unresolved and clears once every one succeeds. Each // A file the library could not decrypt is recorded there too, unless a copy of
// run reconciles the ledger against the files it attempted, so an entry for a // it in another album decrypted; an album it could not decrypt is logged by ID
// file that has since left the library (deleted) or this run's scope is dropped // and listed in `result.undecryptableAlbums`, as its name is not known.
// rather than counted forever, which would poison a scheduled backup's exit code. // `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 { import {
createReadStream,
lstatSync, lstatSync,
mkdirSync, mkdirSync,
readdirSync, readdirSync,
@@ -49,8 +70,16 @@ import {
symlinkSync, symlinkSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { readFile } from "node:fs/promises";
import { dirname, extname, join, relative, resolve } from "node:path"; import { dirname, extname, join, relative, resolve } from "node:path";
import lockfile from "proper-lockfile";
import {
chunkHashFinal,
chunkHashInit,
chunkHashUpdate,
init,
} from "./crypto/index.js";
import { removeLeftoverTempFiles } from "./download/index.js"; import { removeLeftoverTempFiles } from "./download/index.js";
import { sanitizeFileName, withExtension } from "./filename.js"; import { sanitizeFileName, withExtension } from "./filename.js";
import { import {
@@ -60,7 +89,10 @@ import {
storedAtSavePath, storedAtSavePath,
} from "./library/content.js"; } from "./library/content.js";
import { representative } from "./library/records.js"; import { representative } from "./library/records.js";
import type { Collection, EnteFile } from "./model/types.js"; import type { Undecryptable } from "./library/store.js";
import { extractImageMetadata } from "./metadata-backup.js";
import type { MLData } from "./mldata-fetch.js";
import type { Collection, EnteFile, FileMetadata } from "./model/types.js";
export type ProgressCallback = (message: string) => void; export type ProgressCallback = (message: string) => void;
@@ -76,7 +108,14 @@ export interface BackupOptions {
includeThumbnails?: boolean; includeThumbnails?: boolean;
// Restrict the backup to albums with these names; others are left untouched. // Restrict the backup to albums with these names; others are left untouched.
onlyAlbumNames?: string[]; onlyAlbumNames?: string[];
// Hash each original already at its save path, and fetch again any whose
// bytes do not match the content hash its metadata records. Default false.
verify?: boolean;
onProgress?: ProgressCallback; onProgress?: ProgressCallback;
// The caller already holds the lock in `downloadDirectory`, taken with
// `lockBackupDirectory`, and releases it itself, so the backup does not
// take it. `quak backup` takes it before it opens its library.
lockHeld?: boolean;
} }
export interface BackupError { export interface BackupError {
@@ -93,20 +132,35 @@ export interface BackupResult {
downloaded: number; downloaded: number;
// Originals already at their save path and left untouched. // Originals already at their save path and left untouched.
skipped: number; skipped: number;
// With `verify`, the originals already at their save path whose hash
// matched, those whose hash did not (each removed and fetched again), and
// those whose metadata records no hash (left as they are). All three are
// zero without `verify`.
verified: number;
mismatched: number;
unchecked: number;
// Files with an unresolved failure after this run (the ledger size); the // 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 // 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. // downloaded and failed if its bytes landed but its symlink did not.
failed: number; failed: number;
// This run's per-file errors, in encounter order. // This run's per-file errors, in encounter order.
errors: BackupError[]; errors: BackupError[];
// The IDs of the albums the library could not decrypt, whose files it
// could not list; the CLI exits non-zero while there are any.
undecryptableAlbums: number[];
} }
// The slice of the library that backup drives. `Library` implements it; a test // The slice of the library that backup drives. `Library` implements it; a test
// can drive backup with a stand-in. // can drive backup with a stand-in.
export interface BackupLibrary { export interface BackupLibrary {
// The account the library belongs to.
whoami(): { email: string; userID: number };
refresh(): Promise<void>; refresh(): Promise<void>;
listCollections(): Collection[]; listCollections(): Collection[];
listFiles(collectionID: number): EnteFile[]; listFiles(collectionID: number): EnteFile[];
// The albums, and files in albums, that the library could not decrypt,
// which `listCollections` and `listFiles` leave out.
undecryptable(): Undecryptable[];
// Get an original's bytes onto disk through the content cache/pools, // Get an original's bytes onto disk through the content cache/pools,
// returning where they landed: `destination` when they were fetched now, // returning where they landed: `destination` when they were fetched now,
// otherwise wherever they already were (the cache, or the library's save // otherwise wherever they already were (the cache, or the library's save
@@ -117,6 +171,13 @@ export interface BackupLibrary {
destination: string, destination: string,
): Promise<{ path: string; videoPath?: string }>; ): Promise<{ path: string; videoPath?: string }>;
thumbnail(fileID: number): Promise<{ path: string }>; thumbnail(fileID: number): Promise<{ path: string }>;
// Wait for an ML data fetch to complete, joining one already running or
// starting one. Rejects with the reason when it fails; resolves at once
// when the library cannot fetch ML data.
fetchMLData(): Promise<void>;
// A file's cached ML data, as `lib.mldata.forFile()` returns it, or
// undefined when none is cached.
mlData(fileID: number): Promise<MLData | undefined>;
} }
type FailureClass = "transient" | "permanent" | "unknown"; type FailureClass = "transient" | "permanent" | "unknown";
@@ -340,33 +401,126 @@ const saveLedger = (path: string, ledger: Map<number, FailureEntry>): void => {
); );
}; };
const writeSidecar = (path: string, file: EnteFile): void => { // The content hash of the original stored at `stored`, computed as the download
// check computes it: over each file's bytes, read in chunks, and for a live
// photo `<imageHash>:<videoHash>`.
const storedHash = async (stored: {
path: string;
videoPath?: string;
}): Promise<string> => {
await init();
const hashFile = async (path: string): Promise<string> => {
const state = chunkHashInit();
for await (const chunk of createReadStream(path)) {
chunkHashUpdate(state, chunk as Buffer);
}
return chunkHashFinal(state);
};
const hash = await hashFile(stored.path);
if (stored.videoPath === undefined) return hash;
return `${hash}:${await hashFile(stored.videoPath)}`;
};
// A file's EXIF, XMP and dimensions as its JSON holds them: what
// `extractImageMetadata` found in its original, or why the original could not
// be read.
interface ImageMetadata {
imageMetadata?: Record<string, unknown>;
imageMetadataError?: string;
}
// The image metadata for the file whose original is at `originalPath` (for a
// live photo, its image) and whose JSON is at `jsonPath`. A video gets none,
// as `photo.exif()` reads none. An original stored before this run is not read
// again when its JSON already holds image metadata: that is kept. A failed
// read gives the reason, and fails neither the file nor the run. An original
// with no EXIF, XMP or JPEG dimensions gets `{}`, so it is not read again.
const imageMetadataFor = async (
file: EnteFile,
originalPath: string,
jsonPath: string,
storedThisRun: boolean,
): Promise<ImageMetadata> => {
if (file.metadata.fileType === "video") return {};
if (!storedThisRun) {
try {
const { imageMetadata, imageMetadataError } = JSON.parse(
readFileSync(jsonPath, "utf-8"),
) as ImageMetadata;
if (imageMetadata !== undefined || imageMetadataError !== undefined)
return { imageMetadata, imageMetadataError };
} catch {
// No JSON yet, or one that cannot be parsed: read the original.
}
}
try {
const bytes = await readFile(originalPath);
return { imageMetadata: extractImageMetadata(bytes) ?? {} };
} catch (err) {
return { imageMetadataError: errorMessage(err) };
}
};
// The file's JSON: its basic fields, its magic metadata, its ML data or the
// reason the ML data is missing, and its image metadata.
const writeSidecar = (
path: string,
file: EnteFile,
ml: { mlData?: MLData; mlDataError?: string },
image: ImageMetadata,
): void => {
const meta: Record<string, unknown> = { const meta: Record<string, unknown> = {
id: file.id, id: file.id,
collectionID: file.collectionID, collectionID: file.collectionID,
ownerID: file.ownerID, ownerID: file.ownerID,
metadata: file.metadata, metadata: file.metadata,
updationTime: file.updationTime,
}; };
if (file.magicMetadata) meta.magicMetadata = file.magicMetadata; if (file.magicMetadata) meta.magicMetadata = file.magicMetadata;
if (file.pubMagicMetadata) meta.pubMagicMetadata = file.pubMagicMetadata; if (file.pubMagicMetadata) meta.pubMagicMetadata = file.pubMagicMetadata;
if (ml.mlData) meta.mlData = ml.mlData;
if (ml.mlDataError) meta.mlDataError = ml.mlDataError;
if (image.imageMetadata) meta.imageMetadata = image.imageMetadata;
if (image.imageMetadataError) {
meta.imageMetadataError = image.imageMetadataError;
}
writeFileSync(path, JSON.stringify(meta, null, 2)); writeFileSync(path, JSON.stringify(meta, null, 2));
}; };
export const runBackup = async ( // The album's JSON: its basic fields, its magic metadata, and its files.
const writeAlbumJSON = (
path: string,
c: Collection,
files: { id: number; metadata: FileMetadata }[],
): void => {
const album: Record<string, unknown> = {
id: c.id,
name: c.name,
type: c.type,
ownerID: c.ownerID,
isShared: c.isShared,
updationTime: c.updationTime,
};
if (c.magicMetadata) album.magicMetadata = c.magicMetadata;
if (c.pubMagicMetadata) album.pubMagicMetadata = c.pubMagicMetadata;
if (c.sharedMagicMetadata) {
album.sharedMagicMetadata = c.sharedMagicMetadata;
}
album.files = files;
writeFileSync(path, JSON.stringify(album, null, 2));
};
// The backup itself, which `runBackup` below runs while the lock is held.
const runLockedBackup = async (
lib: BackupLibrary, lib: BackupLibrary,
opts: BackupOptions, opts: BackupOptions,
downloadDirectory: string,
): Promise<BackupResult> => { ): Promise<BackupResult> => {
const downloadDirectory = opts.downloadDirectory;
if (!downloadDirectory) {
throw new Error(
"backup requires a downloadDirectory (pass one to backup() or " +
"open the library with one)",
);
}
const includeOriginals = opts.includeOriginals ?? true; const includeOriginals = opts.includeOriginals ?? true;
const includeThumbnails = opts.includeThumbnails ?? false; const includeThumbnails = opts.includeThumbnails ?? false;
const log = opts.onProgress ?? (() => {}); const log = opts.onProgress ?? (() => {});
const only = opts.onlyAlbumNames ? new Set(opts.onlyAlbumNames) : undefined; const only = opts.onlyAlbumNames ? new Set(opts.onlyAlbumNames) : undefined;
const verify = opts.verify ?? false;
log("Refreshing library..."); log("Refreshing library...");
await lib.refresh(); await lib.refresh();
@@ -374,6 +528,11 @@ export const runBackup = async (
const collectionsDir = join(downloadDirectory, "collections"); const collectionsDir = join(downloadDirectory, "collections");
const thumbnailsDir = join(downloadDirectory, "thumbnails"); const thumbnailsDir = join(downloadDirectory, "thumbnails");
mkdirSync(collectionsDir, { recursive: true }); mkdirSync(collectionsDir, { recursive: true });
const { email, userID } = lib.whoami();
writeFileSync(
join(downloadDirectory, "account.json"),
JSON.stringify({ email, userID }, null, 2),
);
if (includeThumbnails) mkdirSync(thumbnailsDir, { recursive: true }); if (includeThumbnails) mkdirSync(thumbnailsDir, { recursive: true });
removeLeftoverTempFiles(thumbnailsDir); removeLeftoverTempFiles(thumbnailsDir);
for (const dir of dateFolders(downloadDirectory)) { for (const dir of dateFolders(downloadDirectory)) {
@@ -417,43 +576,108 @@ export const runBackup = async (
const errors: BackupError[] = []; const errors: BackupError[] = [];
const failedThisRun = new Set<number>(); const failedThisRun = new Set<number>();
const storedThisRun = new Set<number>();
let downloaded = 0; let downloaded = 0;
let skipped = 0; let skipped = 0;
let verified = 0;
let mismatched = 0;
let unchecked = 0;
const recordFailure = ( const recordFailure = (
file: EnteFile, fileID: number,
title: string,
collection: string, collection: string,
err: unknown, err: unknown,
): void => { ): void => {
// Count at most one attempt per file per run: a file whose original // Count at most one attempt per file per run: a file whose original
// and thumbnail both fail this run must not double its attempt count // and thumbnail both fail this run must not double its attempt count
// or appear twice in errors. // or appear twice in errors.
if (failedThisRun.has(file.id)) return; if (failedThisRun.has(fileID)) return;
const error = errorMessage(err); const error = errorMessage(err);
errors.push({ errors.push({ fileID, title, collection, error });
fileID: file.id, const prior = ledger.get(fileID);
title: file.metadata.title, ledger.set(fileID, {
collection, fileID,
error, title,
});
const prior = ledger.get(file.id);
ledger.set(file.id, {
fileID: file.id,
title: file.metadata.title,
classification: classify(err), classification: classify(err),
attempts: (prior?.attempts ?? 0) + 1, attempts: (prior?.attempts ?? 0) + 1,
lastTriedAt: now, lastTriedAt: now,
error, error,
}); });
failedThisRun.add(file.id); failedThisRun.add(fileID);
}; };
// What the library could not decrypt. An album is logged by its ID, as
// its name is not known, so a run limited to some albums logs it too. A
// file in an album in scope is backed up from a copy of it in another
// album that decrypted, even an album out of scope. With no such copy it
// is recorded as failed, with an empty title as its metadata is not known.
const scope = new Set(collections.map((c) => c.id));
const undecryptableAlbums: number[] = [];
for (const record of lib.undecryptable()) {
const error = `could not be decrypted: ${record.error}`;
if (record.fileID === undefined) {
log(`FAILED album ${record.collectionID}: ${error}`);
undecryptableAlbums.push(record.collectionID);
} else if (scope.has(record.collectionID)) {
const copies = memberships.get(record.fileID);
if (copies) {
distinct.set(record.fileID, representative(copies));
} else {
recordFailure(
record.fileID,
"",
collectionName.get(record.collectionID) ?? "",
new Error(error),
);
}
}
}
// Phase 1: get the bytes. Put each pending original at its save path // Phase 1: get the bytes. Put each pending original at its save path
// through the content cache/pools, as `Photo.download()` does, and fetch // through the content cache/pools, as `Photo.download()` does, and fetch
// the optional thumbnails; a present file is left as is. // the optional thumbnails; a present file is left as is. With `verify`, a
// present original is hashed first, and one that does not match the hash
// its metadata records is removed and fetched like a missing one. One
// that cannot be read is recorded as failed and left where it is.
if (includeOriginals) { if (includeOriginals) {
for (const [fileID, file] of distinct) { for (const [fileID, file] of distinct) {
if (storedAtSavePath(downloadDirectory, file) !== undefined) { let stored = storedAtSavePath(downloadDirectory, file);
let mismatch = false;
if (stored !== undefined && verify) {
try {
if (file.metadata.hash === undefined) {
unchecked++;
} else if (
(await storedHash(stored)) === file.metadata.hash
) {
verified++;
} else {
log(
`MISMATCH original ${file.metadata.title} (${fileID}): its bytes do not match its content hash`,
);
mismatched++;
mismatch = true;
rmSync(stored.path);
if (stored.videoPath !== undefined) {
rmSync(stored.videoPath);
}
stored = undefined;
}
} catch (err) {
log(
`FAILED verifying original ${file.metadata.title}: ${errorMessage(err)}`,
);
recordFailure(
file.id,
file.metadata.title,
collectionName.get(file.collectionID) ?? "",
err,
);
continue;
}
}
if (stored !== undefined) {
skipped++; skipped++;
continue; continue;
} }
@@ -462,16 +686,32 @@ export const runBackup = async (
// A fetched original is written straight to its save path (a // A fetched original is written straight to its save path (a
// live photo beside it); only one that was already cached // live photo beside it); only one that was already cached
// elsewhere is copied. // elsewhere is copied.
await placeOriginal(downloadDirectory, file, (dest) => const placed = await placeOriginal(
lib.original(fileID, dest), downloadDirectory,
file,
(dest) => lib.original(fileID, dest),
); );
storedThisRun.add(fileID);
// A copy from the cache is not checked as a download is and can
// hold the same bad bytes, so what is put back after a
// mismatch is hashed too. A bad copy stays where it is and the
// file fails.
if (
mismatch &&
(await storedHash(placed)) !== file.metadata.hash
) {
throw new Error(
"the original put back does not match its content hash either",
);
}
downloaded++; downloaded++;
} catch (err) { } catch (err) {
log( log(
`FAILED original ${file.metadata.title}: ${errorMessage(err)}`, `FAILED original ${file.metadata.title}: ${errorMessage(err)}`,
); );
recordFailure( recordFailure(
file, file.id,
file.metadata.title,
collectionName.get(file.collectionID) ?? "", collectionName.get(file.collectionID) ?? "",
err, err,
); );
@@ -488,7 +728,8 @@ export const runBackup = async (
await copyAtomic(path, dest); await copyAtomic(path, dest);
} catch (err) { } catch (err) {
recordFailure( recordFailure(
file, file.id,
file.metadata.title,
collectionName.get(file.collectionID) ?? "", collectionName.get(file.collectionID) ?? "",
err, err,
); );
@@ -497,12 +738,44 @@ export const runBackup = async (
} }
// Phase 2: rebuild the derived views from the model. Sidecars first, for // Phase 2: rebuild the derived views from the model. Sidecars first, for
// every present original (this repairs stale ones). // every present original (this repairs stale ones), each with the file's
// ML data once an ML data fetch has completed, and its image metadata.
// When the fetch fails, a file with no cached ML data gets the reason
// instead and is recorded as failed. The next run fetches its ML data
// again because none is cached.
if (includeOriginals) { if (includeOriginals) {
let mlDataError: string | undefined;
try {
log("Fetching ML data...");
await lib.fetchMLData();
} catch (err) {
mlDataError = errorMessage(err);
log(`FAILED ML data: ${mlDataError}`);
}
for (const file of distinct.values()) { for (const file of distinct.values()) {
if (storedAtSavePath(downloadDirectory, file) !== undefined) { const stored = storedAtSavePath(downloadDirectory, file);
const path = savePath(downloadDirectory, file); if (stored === undefined) continue;
writeSidecar(withExtension(path, ".json"), file); const path = withExtension(
savePath(downloadDirectory, file),
".json",
);
const image = await imageMetadataFor(
file,
stored.path,
path,
storedThisRun.has(file.id),
);
const mlData = await lib.mlData(file.id);
if (mlData === undefined && mlDataError !== undefined) {
writeSidecar(path, file, { mlDataError }, image);
recordFailure(
file.id,
file.metadata.title,
collectionName.get(file.collectionID) ?? "",
new Error(`ML data: ${mlDataError}`),
);
} else {
writeSidecar(path, file, { mlData }, image);
} }
} }
} }
@@ -570,17 +843,19 @@ export const runBackup = async (
log( log(
`FAILED symlink ${c.name}/${linkName}: ${errorMessage(err)}`, `FAILED symlink ${c.name}/${linkName}: ${errorMessage(err)}`,
); );
recordFailure(link.file, c.name, err); recordFailure(
link.file.id,
link.file.metadata.title,
c.name,
err,
);
} }
} }
writeFileSync( writeAlbumJSON(
join(collectionsDir, `${colDirName}.json`), join(collectionsDir, `${colDirName}.json`),
JSON.stringify( c,
{ id: c.id, name: c.name, type: c.type, files: metaFiles }, metaFiles,
null,
2,
),
); );
} }
@@ -599,7 +874,59 @@ export const runBackup = async (
totalFiles: distinct.size, totalFiles: distinct.size,
downloaded, downloaded,
skipped, skipped,
verified,
mismatched,
unchecked,
failed: ledger.size, failed: ledger.size,
errors, errors,
undecryptableAlbums,
}; };
}; };
// Only one backup of a directory runs at a time, in this process or another.
// Creates `downloadDirectory` if it is missing, takes the lock in it and
// returns the function that releases it; while another backup holds the lock,
// fails at once with an error whose `code` is `ELOCKED`. The lock is the
// directory `backup.lock`, whose modification time proper-lockfile keeps
// current while it is held. One it has not touched for 10 seconds was left by
// a run that could not remove it, and is taken over.
export const lockBackupDirectory = async (
downloadDirectory: string,
): Promise<() => Promise<void>> => {
mkdirSync(downloadDirectory, { recursive: true });
try {
return await lockfile.lock(downloadDirectory, {
lockfilePath: join(downloadDirectory, "backup.lock"),
});
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err;
throw Object.assign(
new Error(`another backup of ${downloadDirectory} is running`),
{ code: "ELOCKED" },
);
}
};
// Runs the backup holding the lock, which it takes and releases itself unless
// the caller already holds it (`opts.lockHeld`).
export const runBackup = async (
lib: BackupLibrary,
opts: BackupOptions,
): Promise<BackupResult> => {
const downloadDirectory = opts.downloadDirectory;
if (!downloadDirectory) {
throw new Error(
"backup requires a downloadDirectory (pass one to backup() or " +
"open the library with one)",
);
}
if (opts.lockHeld) {
return runLockedBackup(lib, opts, downloadDirectory);
}
const release = await lockBackupDirectory(downloadDirectory);
try {
return await runLockedBackup(lib, opts, downloadDirectory);
} finally {
await release();
}
};
+95 -60
View File
@@ -20,6 +20,7 @@ import {
type ClientSnapshot, type ClientSnapshot,
type LoginOptions, type LoginOptions,
} from "./client.js"; } from "./client.js";
import { lockBackupDirectory } from "./backup.js";
import { init } from "./crypto/index.js"; import { init } from "./crypto/index.js";
import { import {
defaultCacheDirectory, defaultCacheDirectory,
@@ -73,7 +74,9 @@ export const saveSession = (
); );
}; };
// The saved client, or undefined after telling the user why there is none. // The saved client, or undefined after telling the user why there is none. The
// command then exits 3, the code for "log in again", as `run` in `cli-run.ts`
// does when the server no longer accepts the saved session.
const requireSession = (ctx: CliContext): Client | undefined => { const requireSession = (ctx: CliContext): Client | undefined => {
let client: Client | null; let client: Client | null;
try { try {
@@ -94,6 +97,20 @@ const requireSession = (ctx: CliContext): Client | undefined => {
return client; return client;
}; };
// `text` as an ID, or undefined after telling the user it is not one. An ID is
// written in decimal digits and is a safe integer; `Number()` alone would read
// `0x10` as 16, `1e3` as 1000 and "" as 0.
const parseID = (
ctx: CliContext,
kind: "file" | "collection",
text: string,
): number | undefined => {
const id = Number(text);
if (/^[0-9]+$/.test(text) && Number.isSafeInteger(id)) return id;
ctx.stderr.write(`Invalid ${kind} ID: ${text}\n`);
return undefined;
};
// A library client that omits `fetchMLData`, so the point commands below do not // A library client that omits `fetchMLData`, so the point commands below do not
// kick the library's background ML backfill: they read metadata, or fetch one // kick the library's background ML backfill: they read metadata, or fetch one
// file's content, and exit. `backup` and `backup-metadata` handle ML on their // file's content, and exit. `backup` and `backup-metadata` handle ML on their
@@ -150,7 +167,7 @@ export const loginCommand = async (ctx: CliContext): Promise<number> => {
export const whoamiCommand = async (ctx: CliContext): Promise<number> => { export const whoamiCommand = async (ctx: CliContext): Promise<number> => {
await init(); await init();
const client = requireSession(ctx); const client = requireSession(ctx);
if (!client) return 1; if (!client) return 3;
const info = client.whoami(); const info = client.whoami();
ctx.stdout.write(JSON.stringify(info) + "\n"); ctx.stdout.write(JSON.stringify(info) + "\n");
return 0; return 0;
@@ -201,7 +218,7 @@ export const collectionsCommand = async (
): Promise<number> => { ): Promise<number> => {
await init(); await init();
const client = requireSession(ctx); const client = requireSession(ctx);
if (!client) return 1; if (!client) return 3;
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
// Force a server round-trip and list in enumeration order (issue #36 // Force a server round-trip and list in enumeration order (issue #36
@@ -243,12 +260,9 @@ export const filesCommand = async (
): Promise<number> => { ): Promise<number> => {
await init(); await init();
const client = requireSession(ctx); const client = requireSession(ctx);
if (!client) return 1; if (!client) return 3;
const collectionID = Number(opts.collection); const collectionID = parseID(ctx, "collection", opts.collection);
if (!Number.isFinite(collectionID)) { if (collectionID === undefined) return 1;
ctx.stderr.write("Invalid collection ID\n");
return 1;
}
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
@@ -285,12 +299,9 @@ export const getCommand = async (
): Promise<number> => { ): Promise<number> => {
await init(); await init();
const client = requireSession(ctx); const client = requireSession(ctx);
if (!client) return 1; if (!client) return 3;
const fileID = Number(fileIDStr); const fileID = parseID(ctx, "file", fileIDStr);
if (!Number.isFinite(fileID)) { if (fileID === undefined) return 1;
ctx.stderr.write("Invalid file ID\n");
return 1;
}
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
@@ -343,12 +354,9 @@ export const getThumbCommand = async (
): Promise<number> => { ): Promise<number> => {
await init(); await init();
const client = requireSession(ctx); const client = requireSession(ctx);
if (!client) return 1; if (!client) return 3;
const fileID = Number(fileIDStr); const fileID = parseID(ctx, "file", fileIDStr);
if (!Number.isFinite(fileID)) { if (fileID === undefined) return 1;
ctx.stderr.write("Invalid file ID\n");
return 1;
}
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
@@ -380,7 +388,7 @@ export const backupMetadataCommand = async (
): Promise<number> => { ): Promise<number> => {
await init(); await init();
const client = requireSession(ctx); const client = requireSession(ctx);
if (!client) return 1; if (!client) return 3;
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
// Refresh first so the dump holds current account state, not what the // Refresh first so the dump holds current account state, not what the
@@ -399,51 +407,74 @@ export const backupMetadataCommand = async (
export const backupCommand = async ( export const backupCommand = async (
ctx: CliContext, ctx: CliContext,
dir: string, dir: string,
opts: { json?: boolean }, opts: { json?: boolean; verify?: boolean },
): Promise<number> => { ): Promise<number> => {
await init(); await init();
const client = requireSession(ctx); const client = requireSession(ctx);
if (!client) return 1; if (!client) return 3;
ctx.stderr.write("Starting backup...\n"); ctx.stderr.write("Starting backup...\n");
// The precache is off: the backup fetches what it needs, and must not // The lock is taken before the library opens and starts its refresh, so a
// also fill the cache with every thumbnail and the recent originals. // run refused while another backup of `dir` runs sends no request.
const lib = await Library.open({ let release: () => Promise<void>;
client,
downloadDirectory: dir,
cacheDirectory: ctx.cacheDir,
precacheThumbnails: false,
precacheOriginals: false,
});
try { try {
const result = await lib.backup({ release = await lockBackupDirectory(dir);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err;
ctx.stderr.write(`quak: ${(err as Error).message}\n`);
return 2;
}
try {
// The precache is off: the backup fetches what it needs, and must not
// also fill the cache with every thumbnail and the recent originals.
const lib = await Library.open({
client,
downloadDirectory: dir, downloadDirectory: dir,
onProgress: (msg) => { cacheDirectory: ctx.cacheDir,
if (!opts.json) ctx.stderr.write(msg + "\n"); precacheThumbnails: false,
}, precacheOriginals: false,
}); });
try {
const result = await lib.backup({
downloadDirectory: dir,
lockHeld: true,
verify: opts.verify,
onProgress: (msg) => {
if (!opts.json) ctx.stderr.write(msg + "\n");
},
});
if (opts.json) { if (opts.json) {
ctx.stdout.write(JSON.stringify(result, null, 2) + "\n"); ctx.stdout.write(JSON.stringify(result, null, 2) + "\n");
} else { } else {
ctx.stderr.write("\n--- Backup complete ---\n"); ctx.stderr.write("\n--- Backup complete ---\n");
ctx.stderr.write(` Total files: ${result.totalFiles}\n`); ctx.stderr.write(` Total files: ${result.totalFiles}\n`);
ctx.stderr.write(` Downloaded: ${result.downloaded}\n`); ctx.stderr.write(` Downloaded: ${result.downloaded}\n`);
ctx.stderr.write(` Skipped: ${result.skipped}\n`); ctx.stderr.write(` Skipped: ${result.skipped}\n`);
ctx.stderr.write(` Failed: ${result.failed}\n`); if (opts.verify) {
if (result.errors.length > 0) { ctx.stderr.write(` Verified: ${result.verified}\n`);
ctx.stderr.write("\nFailed files:\n"); ctx.stderr.write(` Mismatched: ${result.mismatched}\n`);
for (const e of result.errors) { ctx.stderr.write(` Unchecked: ${result.unchecked}\n`);
ctx.stderr.write( }
` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`, ctx.stderr.write(` Failed: ${result.failed}\n`);
); if (result.errors.length > 0) {
ctx.stderr.write("\nFailed files:\n");
for (const e of result.errors) {
ctx.stderr.write(
` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`,
);
}
} }
} }
}
return result.failed > 0 ? 1 : 0; return result.failed > 0 || result.undecryptableAlbums.length > 0
? 1
: 0;
} finally {
await lib.close();
}
} finally { } finally {
await lib.close(); await release();
} }
}; };
@@ -453,7 +484,7 @@ export const listMissingThumbnailsCommand = async (
): Promise<number> => { ): Promise<number> => {
await init(); await init();
const client = requireSession(ctx); const client = requireSession(ctx);
if (!client) return 1; if (!client) return 3;
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
// Refresh first so files added since the cache was written are // Refresh first so files added since the cache was written are
@@ -491,16 +522,20 @@ export const fixMissingThumbnailsCommand = async (
): Promise<number> => { ): Promise<number> => {
await init(); await init();
const client = requireSession(ctx); const client = requireSession(ctx);
if (!client) return 1; if (!client) return 3;
// One bad --file ID fails the whole command, before anything is fetched.
let fileIDs: number[] = [];
for (const text of opts.file ?? []) {
const fileID = parseID(ctx, "file", text);
if (fileID === undefined) return 1;
fileIDs.push(fileID);
}
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
// Refresh first so files added since the cache was written are found; // Refresh first so files added since the cache was written are found;
// a failed refresh throws. // a failed refresh throws.
await lib.fresh(); await lib.fresh();
let fileIDs: number[]; if (fileIDs.length === 0) {
if (opts.file && opts.file.length > 0) {
fileIDs = opts.file.map(Number).filter(Number.isFinite);
} else {
ctx.stderr.write("Scanning for missing thumbnails...\n"); ctx.stderr.write("Scanning for missing thumbnails...\n");
const missing = await listMissingThumbnails(lib, client, (msg) => { const missing = await listMissingThumbnails(lib, client, (msg) => {
if (!opts.json) ctx.stderr.write(msg + "\n"); if (!opts.json) ctx.stderr.write(msg + "\n");
+15 -5
View File
@@ -1,12 +1,15 @@
// Runs one CLI command for `bin/quak.ts` and exits with its code. // Runs one CLI command for `bin/quak.ts` and exits with its code.
import type { Writable } from "node:stream"; import type { Writable } from "node:stream";
import { ApiError } from "./api/client.js";
// Run a command and exit with its code once stdout/stderr have drained. // Run a command and exit with its code once stdout/stderr have drained.
// Exiting before the drain can truncate piped output, and the library can keep // Exiting before the drain can truncate piped output, and the library can keep
// the event loop alive after a command returns, so a plain return could hang. // the event loop alive after a command returns, so a plain return could hang.
// An error the command throws is printed as one `quak: MESSAGE` line, without // An error the command throws is printed as one `quak: MESSAGE` line, without
// the stack trace, and exits 1. // the stack trace, and exits 1. A 401 from the server means it no longer
// accepts the saved session: that prints one line saying to log in again and
// exits 3, as a missing or corrupt session file does.
export const run = async ( export const run = async (
command: Promise<number>, command: Promise<number>,
stdout: Writable, stdout: Writable,
@@ -17,10 +20,17 @@ export const run = async (
try { try {
code = await command; code = await command;
} catch (err) { } catch (err) {
stderr.write( if (err instanceof ApiError && err.status === 401) {
`quak: ${err instanceof Error ? err.message : String(err)}\n`, stderr.write(
); `quak: the saved session is no longer valid; run "quak login"\n`,
code = 1; );
code = 3;
} else {
stderr.write(
`quak: ${err instanceof Error ? err.message : String(err)}\n`,
);
code = 1;
}
} }
const pending = [stdout, stderr].filter((s) => s.writableLength > 0); const pending = [stdout, stderr].filter((s) => s.writableLength > 0);
if (pending.length === 0) { if (pending.length === 0) {
+53 -26
View File
@@ -44,20 +44,28 @@ export interface ClientSnapshot {
// The result of a resumable enumeration. Live decrypted records and deleted // The result of a resumable enumeration. Live decrypted records and deleted
// ids are kept apart on purpose: a tombstone carries no key or metadata to // ids are kept apart on purpose: a tombstone carries no key or metadata to
// decrypt, so it is a bare id rather than a hollowed-out record. `cursor` is // decrypt, so it is a bare id rather than a hollowed-out record. A live row
// the max `updationTime` seen, to pass back into the next call. // that cannot be decrypted, or whose metadata cannot be parsed, is in
// `undecryptable` with the reason instead, so one bad row does not fail the
// call; absent means there was none. `cursor` is the max `updationTime` seen,
// those rows included, to pass back into the next call.
export interface CollectionsPage { export interface CollectionsPage {
collections: Collection[]; collections: Collection[];
deleted: number[]; deleted: number[];
undecryptable?: { id: number; error: string }[];
cursor: number; cursor: number;
} }
export interface FilesPage { export interface FilesPage {
files: EnteFile[]; files: EnteFile[];
deleted: number[]; deleted: number[];
undecryptable?: { id: number; error: string }[];
cursor: number; cursor: number;
} }
const errorMessage = (err: unknown): string =>
err instanceof Error ? err.message : String(err);
export class Client { export class Client {
private readonly api: ApiClient; private readonly api: ApiClient;
private readonly email: string; private readonly email: string;
@@ -237,11 +245,12 @@ export class Client {
} }
// Enumerate collections changed since `sinceTime`. Live collections are // Enumerate collections changed since `sinceTime`. Live collections are
// decrypted; tombstoned ones (isDeleted) are surfaced as bare ids. The // decrypted; tombstoned ones (isDeleted) are surfaced as bare ids, and
// returned cursor is the max `updationTime` seen — including tombstones, so // ones that do not decrypt as ids with the reason. The returned cursor is
// the next sync resumes past them — falling back to `sinceTime` when the // the max `updationTime` seen — including tombstones, so the next sync
// response is empty. `/collections/v2` returns the whole changed set in one // resumes past them — falling back to `sinceTime` when the response is
// response, so there is no pagination here. // empty. `/collections/v2` returns the whole changed set in one response,
// so there is no pagination here.
async collectionsSince(args: { async collectionsSince(args: {
sinceTime: number; sinceTime: number;
}): Promise<CollectionsPage> { }): Promise<CollectionsPage> {
@@ -254,31 +263,40 @@ export class Client {
const collections: Collection[] = []; const collections: Collection[] = [];
const deleted: number[] = []; const deleted: number[] = [];
const undecryptable: { id: number; error: string }[] = [];
let cursor = args.sinceTime; let cursor = args.sinceTime;
for (const raw of raws) { for (const raw of raws) {
if (raw.isDeleted) { if (raw.isDeleted) {
deleted.push(raw.id); deleted.push(raw.id);
} else { } else {
collections.push( try {
decryptCollection( collections.push(
raw, decryptCollection(
{ raw,
masterKey: this.masterKey, {
publicKey: this.publicKey, masterKey: this.masterKey,
secretKey: this.secretKey, publicKey: this.publicKey,
}, secretKey: this.secretKey,
this.userID, },
), this.userID,
); ),
);
} catch (err) {
undecryptable.push({
id: raw.id,
error: errorMessage(err),
});
}
} }
if (raw.updationTime > cursor) cursor = raw.updationTime; if (raw.updationTime > cursor) cursor = raw.updationTime;
} }
return { collections, deleted, cursor }; return { collections, deleted, undecryptable, cursor };
} }
// Enumerate a collection's files changed since `sinceTime`, paginating the // Enumerate a collection's files changed since `sinceTime`, paginating the
// diff from that cursor. Live rows are decrypted; tombstoned ones are // diff from that cursor. Live rows are decrypted; tombstoned ones are
// surfaced as bare ids. Returns the final cursor to resume from. // surfaced as bare ids, and ones that do not decrypt or parse as ids with
// the reason. Returns the final cursor to resume from.
async filesSince(args: { async filesSince(args: {
collectionID: number; collectionID: number;
collectionKey: Uint8Array; collectionKey: Uint8Array;
@@ -288,6 +306,7 @@ export class Client {
const { collectionID, collectionKey } = args; const { collectionID, collectionKey } = args;
const files: EnteFile[] = []; const files: EnteFile[] = [];
const deleted: number[] = []; const deleted: number[] = [];
const undecryptable: { id: number; error: string }[] = [];
let cursor = args.sinceTime; let cursor = args.sinceTime;
for (;;) { for (;;) {
const { diff, hasMore } = await this.api.getJSON<{ const { diff, hasMore } = await this.api.getJSON<{
@@ -300,7 +319,14 @@ export class Client {
if (raw.isDeleted) { if (raw.isDeleted) {
deleted.push(raw.id); deleted.push(raw.id);
} else { } else {
files.push(decryptFile(raw, collectionKey)); try {
files.push(decryptFile(raw, collectionKey));
} catch (err) {
undecryptable.push({
id: raw.id,
error: errorMessage(err),
});
}
} }
if (raw.updationTime > pageMax) pageMax = raw.updationTime; if (raw.updationTime > pageMax) pageMax = raw.updationTime;
} }
@@ -321,18 +347,19 @@ export class Client {
} }
cursor = pageMax; cursor = pageMax;
} }
return { files, deleted, cursor }; return { files, deleted, undecryptable, cursor };
} }
// Whole-account listing: every live collection, deletions hidden. A thin // Whole-account listing: every live collection that decrypts, deletions
// wrapper over `collectionsSince` from the beginning of time. // hidden. A thin wrapper over `collectionsSince` from the beginning of
// time.
async listCollections(): Promise<Collection[]> { async listCollections(): Promise<Collection[]> {
const { collections } = await this.collectionsSince({ sinceTime: 0 }); const { collections } = await this.collectionsSince({ sinceTime: 0 });
return collections; return collections;
} }
// Every live file in a collection, deletions hidden. A thin wrapper over // Every live file in a collection that decrypts, deletions hidden. A thin
// `filesSince` from the beginning of time. // wrapper over `filesSince` from the beginning of time.
async listFiles( async listFiles(
collectionID: number, collectionID: number,
collectionKey: Uint8Array, collectionKey: Uint8Array,
+2
View File
@@ -27,6 +27,7 @@ export {
isRetryable, isRetryable,
isSafeToReplay, isSafeToReplay,
resolveRetryOptions, resolveRetryOptions,
UNATTENDED_RETRY_OPTIONS,
withRetry, withRetry,
type ResolvedRetryOptions, type ResolvedRetryOptions,
type RetryOptions, type RetryOptions,
@@ -66,6 +67,7 @@ export {
type EnsureOptions, type EnsureOptions,
type EnsureResult, type EnsureResult,
type EnsureEvent, type EnsureEvent,
lockBackupDirectory,
runBackup, runBackup,
type BackupOptions, type BackupOptions,
type BackupResult, type BackupResult,
+59 -15
View File
@@ -94,6 +94,7 @@ import type { Collection, EnteFile } from "../model/types.js";
import { runBackup, type BackupOptions, type BackupResult } from "../backup.js"; import { runBackup, type BackupOptions, type BackupResult } from "../backup.js";
export { export {
lockBackupDirectory,
runBackup, runBackup,
type BackupOptions, type BackupOptions,
type BackupResult, type BackupResult,
@@ -279,7 +280,8 @@ export class Library {
private cycle?: Promise<void>; private cycle?: Promise<void>;
// Guards the ML fetch pass so a slow backfill never runs twice at once; a // 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 // refresh whose pass is still running kicks nothing new. Holds the running
// pass, so `close()` can wait for it. // pass, so `close()` and `backup()` can wait for it. It rejects when the
// pass fails.
private mlFetch?: Promise<void>; private mlFetch?: Promise<void>;
private closed = false; private closed = false;
private lastRefreshAt?: number; private lastRefreshAt?: number;
@@ -565,12 +567,17 @@ export class Library {
// Back up every in-scope file to `opts.downloadDirectory`, or else the // Back up every in-scope file to `opts.downloadDirectory`, or else the
// library's, each original at its save path, with a durable failure // library's, each original at its save path, with a durable failure
// ledger (issue #51). Waits for a completed refresh first, as `fresh()` // ledger (issue #51). Takes the lock in that directory first, unless
// does, joining one already running, and rejects before touching any file // `opts.lockHeld` says the caller holds it, failing at once while another
// when it fails. Then puts pending originals at their save paths as // backup of it runs (see `runBackup`). Waits for a
// `Photo.download()` does (and optional thumbnails) through the content // completed refresh, as `fresh()` does, joining one already running, and
// cache and pools, and rebuilds the derived symlink/JSON views from the // rejects before touching any file but the lock when it fails. Then puts
// model. Throws before any network work when no content cache backs the // pending originals at their save paths as `Photo.download()` does (and
// optional thumbnails) through the content cache and pools, waits for an
// ML data fetch, and rebuilds the derived symlink/JSON views from the
// model, each file's JSON with its ML data, beside an `account.json` with
// the account's email and user ID.
// Throws before any network work when no content cache backs the
// originals it must fetch. // originals it must fetch.
backup(opts?: BackupOptions): Promise<BackupResult> { backup(opts?: BackupOptions): Promise<BackupResult> {
const downloadDirectory = const downloadDirectory =
@@ -587,12 +594,16 @@ export class Library {
const cache = this.cache; const cache = this.cache;
return runBackup( return runBackup(
{ {
whoami: () => this.client.whoami(),
refresh: () => this.refreshNow(), refresh: () => this.refreshNow(),
listCollections: () => this.store.listCollections(), listCollections: () => this.store.listCollections(),
listFiles: (id) => this.store.listFiles(id), listFiles: (id) => this.store.listFiles(id),
undecryptable: () => this.store.listUndecryptable(),
original: (fileID, destination) => original: (fileID, destination) =>
cache!.backupOriginal(fileID, destination), cache!.backupOriginal(fileID, destination),
thumbnail: (fileID) => cache!.thumbnail(fileID), thumbnail: (fileID) => cache!.thumbnail(fileID),
fetchMLData: () => this.fetchMLDataNow(),
mlData: (fileID) => this.mldata.forFile({ fileID }),
}, },
{ ...opts, downloadDirectory }, { ...opts, downloadDirectory },
); );
@@ -612,7 +623,7 @@ export class Library {
this.timer = undefined; this.timer = undefined;
} }
await this.cycle?.catch(() => {}); await this.cycle?.catch(() => {});
await this.mlFetch; await this.mlFetch?.catch(() => {});
await precacheClosed; await precacheClosed;
} }
@@ -676,10 +687,9 @@ export class Library {
// Backfill ML data for the files this refresh knows about. It runs // Backfill ML data for the files this refresh knows about. It runs
// outside the refresh's success/failure so a fetch or disk problem // outside the refresh's success/failure so a fetch or disk problem
// there never marks the metadata refresh failed, and it is not // there never marks the metadata refresh failed, and it is not
// awaited so it never stalls the refresh interval. // awaited so it never stalls the refresh interval. Its failure is
this.mlFetch ??= this.runMLFetch().finally(() => { // reported through `status()` and `onProgress`.
this.mlFetch = undefined; void this.fetchMLDataNow().catch(() => {});
});
} catch (err) { } catch (err) {
const error = err instanceof Error ? err.message : String(err); const error = err instanceof Error ? err.message : String(err);
this.lastError = error; this.lastError = error;
@@ -690,7 +700,10 @@ export class Library {
// Fetch every change since the stored cursor, then commit. All network // Fetch every change since the stored cursor, then commit. All network
// reads happen before any store mutation, so a fetch that throws leaves the // reads happen before any store mutation, so a fetch that throws leaves the
// store untouched and the previous snapshot intact. // store untouched and the previous snapshot intact. A collection or file
// that does not decrypt is left out instead, as if deleted, with the reason
// kept in the store: the cursor moves past it, so no later refresh sees it
// again until the server's copy changes.
private async refreshOnce(): Promise<void> { private async refreshOnce(): Promise<void> {
const page = await this.client.collectionsSince({ const page = await this.client.collectionsSince({
sinceTime: this.store.collectionsSinceTime, sinceTime: this.store.collectionsSinceTime,
@@ -727,9 +740,17 @@ export class Library {
this.store.deleteCollection(id); this.store.deleteCollection(id);
changed = true; changed = true;
} }
if (this.store.deleteUndecryptable(id)) changed = true;
}
// A collection left out takes its files with it.
for (const { id, error } of page.undecryptable ?? []) {
this.store.deleteCollection(id);
this.store.putUndecryptable({ collectionID: id, error });
changed = true;
} }
for (const collection of page.collections) { for (const collection of page.collections) {
this.store.putCollection(collection); this.store.putCollection(collection);
this.store.deleteUndecryptable(collection.id);
changed = true; changed = true;
} }
@@ -739,9 +760,22 @@ export class Library {
this.store.deleteFile(collectionID, id); this.store.deleteFile(collectionID, id);
changed = true; changed = true;
} }
if (this.store.deleteUndecryptable(collectionID, id)) {
changed = true;
}
}
for (const { id, error } of filePage.undecryptable ?? []) {
this.store.deleteFile(collectionID, id);
this.store.putUndecryptable({
collectionID,
fileID: id,
error,
});
changed = true;
} }
for (const f of filePage.files) { for (const f of filePage.files) {
this.store.putFile(f); this.store.putFile(f);
this.store.deleteUndecryptable(collectionID, f.id);
changed = true; changed = true;
} }
} }
@@ -784,10 +818,19 @@ export class Library {
} }
} }
// Join the running ML fetch pass, or start one when none runs. Resolves at
// once when the client cannot fetch ML data; rejects when the pass fails.
private fetchMLDataNow(): Promise<void> {
this.mlFetch ??= this.runMLFetch().finally(() => {
this.mlFetch = undefined;
});
return this.mlFetch;
}
// One ML fetch pass: fetch, decrypt and store the ML data for every file // One ML fetch pass: fetch, decrypt and store the ML data for every file
// the store knows about that is not cached (or whose `updationTime` has // the store knows about that is not cached (or whose `updationTime` has
// advanced), through the metadata pool, and update the CLIP index. Guarded // advanced), through the metadata pool, and update the CLIP index. A
// so passes never overlap; a failure is reported, not thrown. // failure is reported through `status()` and `onProgress`, then thrown.
private async runMLFetch(): Promise<void> { private async runMLFetch(): Promise<void> {
const mldata = this.mlStore; const mldata = this.mlStore;
// Bind so the call keeps the client as its receiver when invoked // Bind so the call keeps the client as its receiver when invoked
@@ -831,6 +874,7 @@ export class Library {
const error = err instanceof Error ? err.message : String(err); const error = err instanceof Error ? err.message : String(err);
this.lastMLError = error; this.lastMLError = error;
this.emit({ operation: "fetchMLData", status: "failed", error }); this.emit({ operation: "fetchMLData", status: "failed", error });
throw err;
} }
} }
+45 -5
View File
@@ -2,10 +2,11 @@
// //
// The store keeps one `metadata.json` file holding the account's server // The store keeps one `metadata.json` file holding the account's server
// state: the user id, a schema version, the cursor for the incremental // state: the user id, a schema version, the cursor for the incremental
// collections listing, and the decrypted collection and file records. The // collections listing, the decrypted collection and file records, and the
// whole file is read into RAM on load and rewritten as a whole on save; there // collections and files the refresh could not decrypt. The whole file is read
// is no partial update and no lock file. A separate refresh unit populates the // into RAM on load and rewritten as a whole on save; there is no partial
// store from the server — this module only stores what it is given. // 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 // The file is a cache, so it is never trusted to exist or to be intact: a
// missing or unreadable file loads as an empty store rather than an error, and // missing or unreadable file loads as an empty store rather than an error, and
@@ -33,12 +34,22 @@ const FILE_MODE = 0o600;
type StoredCollection = Omit<Collection, "key"> & { key: string }; type StoredCollection = Omit<Collection, "key"> & { key: string };
type StoredFile = Omit<EnteFile, "key"> & { key: string }; type StoredFile = Omit<EnteFile, "key"> & { key: string };
// A collection, or with `fileID` a file in it, that the last refresh to fetch
// it could not decrypt or parse, and why. It is kept until a later refresh
// fetches it again, because a refresh fetches only what changed.
export interface Undecryptable {
collectionID: number;
fileID?: number;
error: string;
}
interface StoredMetadata { interface StoredMetadata {
schemaVersion: number; schemaVersion: number;
userID: number; userID: number;
collectionsSinceTime: Microseconds; collectionsSinceTime: Microseconds;
collections: StoredCollection[]; collections: StoredCollection[];
files: StoredFile[]; files: StoredFile[];
undecryptable: Undecryptable[];
} }
const encodeKey = (key: Uint8Array): string => const encodeKey = (key: Uint8Array): string =>
@@ -67,6 +78,7 @@ export class MetadataStore {
private readonly collections = new Map<number, Collection>(); private readonly collections = new Map<number, Collection>();
private readonly files = new Map<string, EnteFile>(); private readonly files = new Map<string, EnteFile>();
private undecryptable: Undecryptable[] = [];
private constructor(path: string) { private constructor(path: string) {
this.path = path; this.path = path;
@@ -107,6 +119,7 @@ export class MetadataStore {
}; };
store.files.set(fileKey(file.collectionID, file.id), file); store.files.set(fileKey(file.collectionID, file.id), file);
} }
store.undecryptable = parsed.undecryptable ?? [];
} catch { } catch {
// Any corruption discards the partial result: a half-read cache is // Any corruption discards the partial result: a half-read cache is
// worse than an empty one, since the refresh unit will rebuild it. // worse than an empty one, since the refresh unit will rebuild it.
@@ -134,6 +147,7 @@ export class MetadataStore {
...f, ...f,
key: encodeKey(f.key), key: encodeKey(f.key),
})), })),
undecryptable: this.undecryptable,
}; };
const dir = dirname(this.path); const dir = dirname(this.path);
@@ -165,7 +179,8 @@ export class MetadataStore {
this.collections.set(collection.id, collection); this.collections.set(collection.id, collection);
} }
// Removing a collection also drops its file memberships: a file record is // Removing a collection also drops its file memberships, and any record
// that it or one of its files could not be decrypted: a file record is
// only meaningful as part of a collection the cache still knows about. // only meaningful as part of a collection the cache still knows about.
deleteCollection(id: number): void { deleteCollection(id: number): void {
this.collections.delete(id); this.collections.delete(id);
@@ -174,6 +189,9 @@ export class MetadataStore {
this.files.delete(key); this.files.delete(key);
} }
} }
this.undecryptable = this.undecryptable.filter(
(record) => record.collectionID !== id,
);
} }
getFile(collectionID: number, fileID: number): EnteFile | undefined { getFile(collectionID: number, fileID: number): EnteFile | undefined {
@@ -205,4 +223,26 @@ export class MetadataStore {
deleteFile(collectionID: number, fileID: number): void { deleteFile(collectionID: number, fileID: number): void {
this.files.delete(fileKey(collectionID, fileID)); this.files.delete(fileKey(collectionID, fileID));
} }
listUndecryptable(): Undecryptable[] {
return [...this.undecryptable];
}
// Replaces any earlier record of the same collection or file.
putUndecryptable(record: Undecryptable): void {
this.deleteUndecryptable(record.collectionID, record.fileID);
this.undecryptable.push(record);
}
// Forgets the record of the collection, or with `fileID` of that file in
// it. Returns whether there was one.
deleteUndecryptable(collectionID: number, fileID?: number): boolean {
const before = this.undecryptable.length;
this.undecryptable = this.undecryptable.filter(
(record) =>
record.collectionID !== collectionID ||
record.fileID !== fileID,
);
return this.undecryptable.length < before;
}
} }
+10
View File
@@ -37,6 +37,16 @@ export const DEFAULT_RETRY_OPTIONS: ResolvedRetryOptions = {
random: Math.random, random: Math.random,
}; };
// For a run nobody is watching, such as `quak backup` from cron. A request that
// keeps failing waits at most 243 s in all before it gives up, usually about
// half that, since each wait is drawn at random below its ceiling.
export const UNATTENDED_RETRY_OPTIONS: ResolvedRetryOptions = {
...DEFAULT_RETRY_OPTIONS,
attempts: 10,
baseDelayMs: 1_000,
maxDelayMs: 60_000,
};
export const resolveRetryOptions = ( export const resolveRetryOptions = (
opts?: RetryOptions, opts?: RetryOptions,
): ResolvedRetryOptions => ({ ): ResolvedRetryOptions => ({
+635
View File
@@ -0,0 +1,635 @@
/**
* `quak backup`, end to end.
*
* Each test runs the command as cron runs it: `backupCommand` reads the
* session file that logging in saved, opens the library on the real `Client`
* and backs the whole account up into a directory, and `run` turns the outcome
* into the exit code, as `bin/quak.ts` does. Only the server is a stand-in:
* `buildMetaFetch` from `test/ente-server.ts` answers the Ente HTTP API from
* data this file encrypts with libsodium, as Ente's clients encrypt it. So
* quak's own code fetches each original, decrypts it, checks it against the
* hash its uploader recorded and unpacks a live photo, and quak's own retries
* repeat a failed request.
*
* The account, user 42, has three albums:
*
* Trips its own: beach.jpg
* Family its own: beach.jpg and the live photo IMG_0002.HEIC
* Friend's Wedding shared with it by user 99: toast.mp4
*
* beach.jpg is one file in two albums. It holds EXIF (a Canon EOS R5), and
* Ente has ML data for it. All three files were taken at noon on 1 March 2024.
*/
import {
existsSync,
lstatSync,
mkdtempSync,
readdirSync,
readFileSync,
readlinkSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Writable } from "node:stream";
import { gzipSync } from "node:zlib";
import sodium from "libsodium-wrappers-sumo";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
backupCommand,
saveSession,
type CliContext,
} from "../../src/cli-commands.js";
import { run } from "../../src/cli-run.js";
import { loadSession } from "../../src/cli-session.js";
import { Client } from "../../src/client.js";
import { init, toBase64 } from "../../src/crypto/index.js";
import { UNATTENDED_RETRY_OPTIONS } from "../../src/retry.js";
import {
buildAccount,
buildMetaFetch,
encryptSecretbox,
encryptStreamBlob,
} from "../ente-server.js";
import { HEIC_WITH_EXIF } from "../exif-heic.js";
import { JPEG_WITH_EXIF } from "../exif-jpeg.js";
import { blake2b, livePhotoHash, livePhotoZip, VIDEO } from "../live-photo.js";
const EMAIL = "backup@example.com";
const PASSWORD = "backup-password";
// The fake server logs every account in as user 42.
const USER_ID = 42;
const FRIEND_ID = 99;
// Noon on 1 March 2024 in the time zone of the machine running the test, the
// one quak dates save paths in, in microseconds, as Ente records times.
const TAKEN = new Date(2024, 2, 1, 12).getTime() * 1000;
// The date folder every original is saved in.
const DAY = "2024/2024-03/2024-03-01";
const TOAST = new TextEncoder().encode("a few seconds of the wedding toast");
// Ente's ML data for beach.jpg: a face it found, and the photo's CLIP
// embedding.
const ML_DATA = {
face: { faces: [{ faceID: "100_1", score: 0.98 }] },
clip: { embedding: [0.5, 0.6, 0.7] },
};
// Each file's ID, its owner, its original as uploaded, and the metadata its
// uploader recorded. `fileType` is Ente's number for the kind: 0 an image, 1 a
// video, 2 a live photo, whose original is a ZIP of its image and its video.
// `hash` is the content hash a download must match.
const BEACH = {
id: 100,
ownerID: USER_ID,
original: JPEG_WITH_EXIF,
metadata: {
title: "beach.jpg",
fileType: 0,
hash: blake2b(JPEG_WITH_EXIF),
},
};
const TOAST_VIDEO = {
id: 200,
ownerID: FRIEND_ID,
original: TOAST,
metadata: { title: "toast.mp4", fileType: 1, hash: blake2b(TOAST) },
};
const LIVE_PHOTO = {
id: 300,
ownerID: USER_ID,
original: livePhotoZip({
"image.heic": HEIC_WITH_EXIF,
"video.mov": VIDEO,
}),
metadata: {
title: "IMG_0002.HEIC",
fileType: 2,
hash: livePhotoHash(HEIC_WITH_EXIF, VIDEO),
},
};
// The fake Ente server, as the `fetch` quak sends its requests through.
let server: typeof globalThis.fetch;
let root: string;
let sessionDir: string;
// The fake server, holding the account encrypted as Ente's clients upload it.
const buildServer = async () => {
const account = await buildAccount(PASSWORD);
// An album as `/collections/v2` lists it, its name encrypted with its own
// key. An album of the account's own carries that key encrypted with the
// master key; an album shared with it carries the key sealed to its public
// key, and no nonce.
const album = (id: number, name: string, ownerID: number) => {
const key = sodium.crypto_secretbox_keygen();
const encryptedName = encryptSecretbox(
new TextEncoder().encode(name),
key,
);
let keyFields: { encryptedKey: string; keyDecryptionNonce?: string };
if (ownerID === USER_ID) {
const encryptedKey = encryptSecretbox(key, account.masterKey);
keyFields = {
encryptedKey: toBase64(encryptedKey.ciphertext),
keyDecryptionNonce: toBase64(encryptedKey.nonce),
};
} else {
const sealedKey = sodium.crypto_box_seal(key, account.publicKey);
keyFields = { encryptedKey: toBase64(sealedKey) };
}
const row = {
id,
owner: { id: ownerID },
...keyFields,
encryptedName: toBase64(encryptedName.ciphertext),
nameDecryptionNonce: toBase64(encryptedName.nonce),
type: "album",
updationTime: TAKEN,
};
return { key, row };
};
const trips = album(10, "Trips", USER_ID);
const family = album(20, "Family", USER_ID);
const wedding = album(30, "Friend's Wedding", FRIEND_ID);
// Each file's original is encrypted with the file's own key, as one
// secretstream, which the server serves whole.
const encrypted = (file: typeof BEACH) => {
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
return { file, key, content: encryptStreamBlob(file.original, key) };
};
const beach = encrypted(BEACH);
const toast = encrypted(TOAST_VIDEO);
const livePhoto = encrypted(LIVE_PHOTO);
// A file as an album's `/collections/v2/diff` lists it: its key encrypted
// with the album's key, its metadata encrypted with its own key, and the
// header that starts the decryption of its original.
const inAlbum = (
{ file, key, content }: ReturnType<typeof encrypted>,
collectionID: number,
albumKey: Uint8Array,
) => {
const encryptedKey = encryptSecretbox(key, albumKey);
const metadata = encryptStreamBlob(
new TextEncoder().encode(
JSON.stringify({
...file.metadata,
creationTime: TAKEN,
modificationTime: TAKEN,
}),
),
key,
);
return {
id: file.id,
collectionID,
ownerID: file.ownerID,
encryptedKey: toBase64(encryptedKey.ciphertext),
keyDecryptionNonce: toBase64(encryptedKey.nonce),
metadata: {
encryptedData: toBase64(metadata.ciphertext),
decryptionHeader: toBase64(metadata.header),
},
file: { decryptionHeader: toBase64(content.header) },
thumbnail: {
decryptionHeader: toBase64(sodium.randombytes_buf(24)),
},
updationTime: TAKEN,
};
};
// ML data is gzipped JSON, encrypted with the file's key.
const mlData = encryptStreamBlob(
gzipSync(JSON.stringify(ML_DATA)),
beach.key,
);
return buildMetaFetch({
verifier: account.verifier,
srpAttributes: account.srpAttributes,
keyAttributes: account.keyAttributes,
encryptedToken: account.encryptedToken,
collections: [trips.row, family.row, wedding.row],
filesByCollection: {
10: [inAlbum(beach, 10, trips.key)],
20: [
inAlbum(beach, 20, family.key),
inAlbum(livePhoto, 20, family.key),
],
30: [inAlbum(toast, 30, wedding.key)],
},
encryptedMLData: {
100: {
encryptedData: toBase64(mlData.ciphertext),
decryptionHeader: toBase64(mlData.header),
},
},
fileCiphertexts: {
100: beach.content.ciphertext,
200: toast.content.ciphertext,
300: livePhoto.content.ciphertext,
},
});
};
beforeAll(async () => {
await init();
await sodium.ready;
root = mkdtempSync(join(tmpdir(), "quak-backup-end-to-end-"));
server = await buildServer();
// Log in once, as `quak login` does: the session file it saves is what
// every backup below reads.
sessionDir = join(root, "session");
const client = await Client.login({
email: EMAIL,
password: PASSWORD,
apiOptions: { fetch: server },
});
saveSession(sessionDir, client.toJSON());
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });
});
// Run `quak backup <dir>` as `bin/quak.ts` runs it, sending its requests
// through `fetch`, and return its exit code and everything it printed on
// stderr. Its requests are retried as those of `quak backup` are, except that
// a retry does not wait. Its cache is beside `dir`, so each run of one
// directory finds the cache the run before it left, as cron's runs find the
// user's.
const backup = async (
dir: string,
opts: { verify?: boolean } = {},
fetch = server,
): Promise<{ code: number; stderr: string }> => {
let printed = "";
const stderr = new Writable({
write(chunk: Buffer, _encoding, done) {
printed += chunk.toString();
done();
},
});
const stdout = new Writable({
write(_chunk, _encoding, done) {
done();
},
});
// A backup never prompts: any of these ends the run with exit 1.
const prompted = async (): Promise<never> => {
throw new Error("quak backup prompted");
};
const ctx: CliContext = {
stdout,
stderr,
sessionDir,
cacheDir: `${dir}-cache`,
loadSession: (path) =>
loadSession(path, {
fetch,
retry: { ...UNATTENDED_RETRY_OPTIONS, sleep: async () => {} },
}),
login: prompted,
prompt: prompted,
promptSecret: prompted,
};
const code = await new Promise<number>((resolve) => {
void run(backupCommand(ctx, dir, opts), stdout, stderr, resolve);
});
return { code, stderr: printed };
};
// The ID of the file whose original a request downloads, or undefined for any
// other request. quak downloads an original from
// `https://files.ente.io/?fileID=<id>`.
const downloadOf = (input: RequestInfo | URL): number | undefined => {
const url = new URL(String(input));
return url.hostname === "files.ente.io"
? Number(url.searchParams.get("fileID"))
: undefined;
};
// The fake server, pushing onto `downloads` the ID of each file whose original
// is downloaded.
const recordingDownloads =
(downloads: number[]): typeof globalThis.fetch =>
async (input, init) => {
const fileID = downloadOf(input);
if (fileID !== undefined) downloads.push(fileID);
return server(input, init);
};
// Every entry under `dir`, relative to it, sorted, a symlink as
// `name -> target`.
const listTree = (dir: string): string[] =>
readdirSync(dir, { recursive: true })
.sort()
.map((name) => {
const path = join(dir, name);
return lstatSync(path).isSymbolicLink()
? `${name} -> ${readlinkSync(path)}`
: name;
});
const readJSON = (path: string) => JSON.parse(readFileSync(path, "utf-8"));
describe("the first backup of the account", () => {
let dir: string;
let code: number;
const downloads: number[] = [];
beforeAll(async () => {
dir = mkdtempSync(join(root, "backup-"));
({ code } = await backup(dir, {}, recordingDownloads(downloads)));
});
it("exits 0, having downloaded each file once", () => {
expect(code).toBe(0);
// beach.jpg is in two albums but is downloaded, and stored, once.
expect(downloads.sort()).toEqual([100, 200, 300]);
});
// The tree "Backup layout" in the README describes. Each original is at
// its save path, `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>`, with
// the extension of its title; the live photo is its image and its video,
// each with the extension it had inside the ZIP, and a `.livephoto.json`
// naming the two. Beside each original is its JSON. Each album is a
// folder of links named after the files' titles, with a JSON file beside
// it. No file failed, so there is no `failures.json`, and the run removed
// its lock, `backup.lock`, as it ended.
it("lays the backup out as the README describes", () => {
expect(listTree(dir)).toEqual([
"2024",
"2024/2024-03",
"2024/2024-03/2024-03-01",
"2024/2024-03/2024-03-01/2024-03-01.100.jpg",
"2024/2024-03/2024-03-01/2024-03-01.100.json",
"2024/2024-03/2024-03-01/2024-03-01.200.json",
"2024/2024-03/2024-03-01/2024-03-01.200.mp4",
"2024/2024-03/2024-03-01/2024-03-01.300.heic",
"2024/2024-03/2024-03-01/2024-03-01.300.json",
"2024/2024-03/2024-03-01/2024-03-01.300.livephoto.json",
"2024/2024-03/2024-03-01/2024-03-01.300.mov",
"account.json",
"collections",
"collections/Family",
"collections/Family.json",
"collections/Family/IMG_0002.heic -> ../../2024/2024-03/2024-03-01/2024-03-01.300.heic",
"collections/Family/IMG_0002.mov -> ../../2024/2024-03/2024-03-01/2024-03-01.300.mov",
"collections/Family/beach.jpg -> ../../2024/2024-03/2024-03-01/2024-03-01.100.jpg",
"collections/Friend's Wedding",
"collections/Friend's Wedding.json",
"collections/Friend's Wedding/toast.mp4 -> ../../2024/2024-03/2024-03-01/2024-03-01.200.mp4",
"collections/Trips",
"collections/Trips.json",
"collections/Trips/beach.jpg -> ../../2024/2024-03/2024-03-01/2024-03-01.100.jpg",
]);
});
it("stores each original decrypted, a live photo as its image and its video", () => {
const day = join(dir, DAY);
expect(readFileSync(join(day, "2024-03-01.100.jpg"))).toEqual(
Buffer.from(JPEG_WITH_EXIF),
);
expect(readFileSync(join(day, "2024-03-01.200.mp4"))).toEqual(
Buffer.from(TOAST),
);
expect(readFileSync(join(day, "2024-03-01.300.heic"))).toEqual(
Buffer.from(HEIC_WITH_EXIF),
);
expect(readFileSync(join(day, "2024-03-01.300.mov"))).toEqual(
Buffer.from(VIDEO),
);
expect(readJSON(join(day, "2024-03-01.300.livephoto.json"))).toEqual({
image: "2024-03-01.300.heic",
video: "2024-03-01.300.mov",
});
});
// A file's JSON holds what Ente records for it, decrypted, and what quak
// read from its original: its ML data when Ente has any, and, for an
// image, its EXIF. A video gets no image metadata.
it("writes each file's JSON with its ML data and image metadata", () => {
const day = join(dir, DAY);
const beach = readJSON(join(day, "2024-03-01.100.json"));
expect(beach).toMatchObject({
id: 100,
ownerID: USER_ID,
metadata: {
title: "beach.jpg",
fileType: "image",
creationTime: TAKEN,
hash: BEACH.metadata.hash,
},
updationTime: TAKEN,
});
expect(beach.mlData).toEqual(ML_DATA);
expect(beach.imageMetadata.exif.Make.description).toBe("Canon");
expect(beach.imageMetadata.exif.Model.description).toBe("EOS R5");
const toast = readJSON(join(day, "2024-03-01.200.json"));
expect(toast).toMatchObject({
id: 200,
ownerID: FRIEND_ID,
metadata: { title: "toast.mp4", fileType: "video" },
});
expect(toast).not.toHaveProperty("mlData");
expect(toast).not.toHaveProperty("imageMetadata");
expect(toast).not.toHaveProperty("imageMetadataError");
// A live photo's image metadata is read from its image.
const livePhoto = readJSON(join(day, "2024-03-01.300.json"));
expect(livePhoto).toMatchObject({
id: 300,
ownerID: USER_ID,
metadata: {
title: "IMG_0002.HEIC",
fileType: "livePhoto",
hash: LIVE_PHOTO.metadata.hash,
},
});
expect(livePhoto.imageMetadata.exif.Make.description).toBe("Canon");
});
it("writes each album's JSON and the account's", () => {
const albumJSON = (name: string) =>
readJSON(join(dir, "collections", `${name}.json`));
const fileIDs = (album: { files: { id: number }[] }) =>
album.files.map((f) => f.id);
const trips = albumJSON("Trips");
expect(trips).toMatchObject({
id: 10,
name: "Trips",
type: "album",
ownerID: USER_ID,
isShared: false,
updationTime: TAKEN,
});
expect(fileIDs(trips)).toEqual([100]);
const family = albumJSON("Family");
expect(family).toMatchObject({ id: 20, isShared: false });
expect(fileIDs(family)).toEqual([100, 300]);
const wedding = albumJSON("Friend's Wedding");
expect(wedding).toMatchObject({
id: 30,
ownerID: FRIEND_ID,
isShared: true,
});
expect(fileIDs(wedding)).toEqual([200]);
expect(readJSON(join(dir, "account.json"))).toEqual({
email: EMAIL,
userID: USER_ID,
});
});
});
describe("quak backup", () => {
// An original already at its save path is complete, so a later run, such
// as the next night's from cron, only rewrites the JSON files and the
// album links.
it("downloads nothing on a second run, and exits 0", async () => {
const dir = mkdtempSync(join(root, "backup-"));
expect((await backup(dir)).code).toBe(0);
const firstTree = listTree(dir);
const downloads: number[] = [];
const { code } = await backup(dir, {}, recordingDownloads(downloads));
expect(code).toBe(0);
expect(downloads).toEqual([]);
expect(listTree(dir)).toEqual(firstTree);
});
// The files CDN is briefly unavailable for toast.mp4: its first two
// downloads answer 503. A 5xx is worth repeating, so quak tries again,
// here without waiting, and the third download succeeds.
it("stores a file whose download fails twice with 503 and then succeeds, and exits 0", async () => {
let attempts = 0;
const fetch: typeof globalThis.fetch = async (input, init) => {
if (downloadOf(input) === 200 && ++attempts <= 2) {
return new Response("unavailable", { status: 503 });
}
return server(input, init);
};
const dir = mkdtempSync(join(root, "backup-"));
const { code } = await backup(dir, {}, fetch);
expect(code).toBe(0);
expect(attempts).toBe(3);
expect(readFileSync(join(dir, DAY, "2024-03-01.200.mp4"))).toEqual(
Buffer.from(TOAST),
);
expect(existsSync(join(dir, "failures.json"))).toBe(false);
});
// The server answers 404 for toast.mp4 on every try. A 404 is an answer,
// not a hiccup, so quak does not repeat it: the file goes into
// `failures.json` with the kind of failure, how many times it has been
// tried and why, the backup goes on with the other files, and the exit
// code is 1, so the next run tries again.
it("records a file whose download always answers 404 in failures.json, stores the rest, and exits 1", async () => {
let attempts = 0;
const fetch: typeof globalThis.fetch = async (input, init) => {
if (downloadOf(input) === 200) {
attempts++;
return new Response("not found", { status: 404 });
}
return server(input, init);
};
const dir = mkdtempSync(join(root, "backup-"));
const { code, stderr } = await backup(dir, {}, fetch);
expect(code).toBe(1);
expect(attempts).toBe(1);
const failures = readJSON(join(dir, "failures.json"));
expect(Object.keys(failures.files)).toEqual(["200"]);
expect(failures.files["200"]).toMatchObject({
fileID: 200,
title: "toast.mp4",
classification: "permanent",
attempts: 1,
});
expect(failures.files["200"].error).toContain("404");
expect(stderr).toContain("[Friend's Wedding] toast.mp4 (id 200): ");
// The rest of the account is backed up; the video is not, and its
// album folder has no link to it.
expect(readFileSync(join(dir, DAY, "2024-03-01.100.jpg"))).toEqual(
Buffer.from(JPEG_WITH_EXIF),
);
expect(readFileSync(join(dir, DAY, "2024-03-01.300.heic"))).toEqual(
Buffer.from(HEIC_WITH_EXIF),
);
expect(existsSync(join(dir, DAY, "2024-03-01.200.mp4"))).toBe(false);
expect(
readdirSync(join(dir, "collections", "Friend's Wedding")),
).toEqual([]);
});
// The server no longer accepts the saved session's token, as after
// `quak logout` elsewhere, and answers every request with 401. The run
// stops at the refresh it starts with, says to log in again, and exits 3.
// It took its lock first, so every request was sent while the lock was
// there, and it removed the lock as it ended: the directory is left as it
// was, empty.
it("exits 3 when the refresh is answered with 401, writing nothing but the lock", async () => {
const dir = mkdtempSync(join(root, "backup-"));
const lockAtEachRequest: boolean[] = [];
const fetch: typeof globalThis.fetch = async () => {
lockAtEachRequest.push(existsSync(join(dir, "backup.lock")));
return new Response(null, { status: 401 });
};
const { code, stderr } = await backup(dir, {}, fetch);
expect(code).toBe(3);
expect(stderr).toBe(
"Starting backup...\n" +
"Refreshing library...\n" +
`quak: the saved session is no longer valid; run "quak login"\n`,
);
expect(lockAtEachRequest).not.toHaveLength(0);
expect(lockAtEachRequest).not.toContain(false);
expect(readdirSync(dir)).toEqual([]);
});
// Something changed beach.jpg on disk after it was backed up. A run with
// --verify hashes every stored original, finds that one no longer matches
// the hash Ente recorded, says so, and downloads it again; it put back the
// right bytes, so the exit code is 0.
it("--verify puts back an original that changed on disk, and exits 0", async () => {
const dir = mkdtempSync(join(root, "backup-"));
expect((await backup(dir)).code).toBe(0);
const beach = join(dir, DAY, "2024-03-01.100.jpg");
writeFileSync(beach, "not the photo any more");
const downloads: number[] = [];
const { code, stderr } = await backup(
dir,
{ verify: true },
recordingDownloads(downloads),
);
expect(code).toBe(0);
expect(downloads).toEqual([100]);
expect(readFileSync(beach)).toEqual(Buffer.from(JPEG_WITH_EXIF));
expect(stderr).toContain(
"MISMATCH original beach.jpg (100): its bytes do not match its content hash",
);
expect(stderr).toContain(" Verified: 2\n Mismatched: 1\n");
});
});
+462
View File
@@ -0,0 +1,462 @@
/**
* `quak backup` over an account holding an album or a file quak cannot decrypt
* (issue 181).
*
* The server sends every album and file encrypted, and a refresh decrypts each
* one it fetches. A record whose key no key of the account opens, or whose
* metadata is not JSON, must not fail the refresh: a refresh that fails commits
* nothing, so `quak backup` would store nothing, on every run, until the
* server's copy changed. Instead the refresh leaves that record out, and the
* library keeps a list of what it left out in its cache. It has to: the next
* refresh asks the server only for what changed since the last one, so it will
* not see the record again.
*
* The backup stores every other file. It records each file it could not
* decrypt in `failures.json`, prints one line for each album it could not
* decrypt, and exits 1 while any remain. Once the server sends a copy that
* decrypts, or deletes the record, the next run drops it and can exit 0. The
* read commands answer from what did decrypt.
*
* These tests run the commands with a real saved session over a stand-in Ente
* server, which serves albums, files and originals encrypted with libsodium as
* the real server does. A record is broken the way a corrupt one arrives: its
* key replaced by bytes no key opens, or its metadata text that is not JSON.
*/
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import sodium from "libsodium-wrappers-sumo";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
backupCommand,
collectionsCommand,
filesCommand,
saveSession,
type CliContext,
} from "../../src/cli-commands.js";
import { loadSession } from "../../src/cli-session.js";
import { Client, type ClientSnapshot } from "../../src/client.js";
import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js";
import { Library } from "../../src/library/index.js";
const USER_ID = 42;
// Every file is taken at noon local time on 2026-03-01, in microseconds as Ente
// stores times, so its original is saved in the folder `DAY` whatever the time
// zone of the machine running the tests.
const TAKEN = new Date(2026, 2, 1, 12).getTime() * 1000;
const DAY = join("2026", "2026-03", "2026-03-01");
type Row = { id: number; updationTime: number } & Record<string, unknown>;
// `plaintext` sealed under `key` with a fresh nonce, both base64, as Ente
// sends a key or an album name.
const secretbox = (
plaintext: Uint8Array,
key: Uint8Array,
): { ciphertext: string; nonce: string } => {
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
return {
ciphertext: toBase64(
sodium.crypto_secretbox_easy(plaintext, nonce, key),
),
nonce: toBase64(nonce),
};
};
// What a corrupt row carries in place of its sealed key: random bytes of the
// same length, which no key opens.
const corruptKey = (): string =>
toBase64(
sodium.randombytes_buf(
sodium.crypto_secretbox_KEYBYTES + sodium.crypto_secretbox_MACBYTES,
),
);
const json = (body: unknown): Response =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
// A stand-in Ente server for one account. It holds each album, and each file in
// an album, as the row `/collections/v2` and `/collections/v2/diff` serve, and
// answers a request with the rows whose `updationTime` is past the request's
// `sinceTime`, as the real server does. A change to a file also moves its
// album's `updationTime`, which is what tells a client to list the album's
// files again. Originals are served by file ID, as the files CDN serves them.
class FakeEnte {
private readonly masterKey = sodium.crypto_secretbox_keygen();
private readonly keyPair = sodium.crypto_box_keypair();
private readonly albums = new Map<number, Row>();
private readonly albumKeys = new Map<number, Uint8Array>();
// Each album's file rows, by album ID and then file ID.
private readonly files = new Map<number, Map<number, Row>>();
// Each file's key and its original as served, by file ID. A file in two
// albums is one original under one key, sealed under each album's key.
private readonly originals = new Map<
number,
{ key: Uint8Array; header: string; body: Uint8Array }
>();
// The session `quak login` would have saved for the account.
snapshot(): ClientSnapshot {
return {
email: "user@example.com",
userID: USER_ID,
token: "token",
masterKey: toBase64(this.masterKey),
secretKey: toBase64(this.keyPair.privateKey),
publicKey: toBase64(this.keyPair.publicKey),
};
}
// Album `id`, named `name`, as of time `t`. With `corrupt`, its key cannot
// be decrypted. An album keeps its key from one call to the next, so its
// files' rows still decrypt once it is put back without `corrupt`.
putAlbum(id: number, name: string, t: number, corrupt = false): void {
const key = this.albumKeys.get(id) ?? sodium.crypto_secretbox_keygen();
this.albumKeys.set(id, key);
const sealedKey = secretbox(key, this.masterKey);
const sealedName = secretbox(new TextEncoder().encode(name), key);
this.albums.set(id, {
id,
owner: { id: USER_ID },
encryptedKey: corrupt ? corruptKey() : sealedKey.ciphertext,
keyDecryptionNonce: sealedKey.nonce,
encryptedName: sealedName.ciphertext,
nameDecryptionNonce: sealedName.nonce,
type: "album",
updationTime: t,
});
if (!this.files.has(id)) this.files.set(id, new Map());
}
// File `id`, titled `photo-<id>.jpg`, in album `albumID` as of time `t`.
// With `corruptKey`, its key cannot be decrypted. With `metadata`, its
// metadata decrypts to that text instead of the JSON a client uploads.
putFile(
albumID: number,
id: number,
t: number,
opts: { corruptKey?: boolean; metadata?: string } = {},
): void {
const original = this.originalOf(id);
const sealedKey = secretbox(original.key, this.albumKeys.get(albumID)!);
const metadata =
opts.metadata ??
JSON.stringify({
title: `photo-${id}.jpg`,
fileType: 0,
creationTime: TAKEN,
modificationTime: TAKEN,
});
const sealedMetadata = encryptBlob(
new TextEncoder().encode(metadata),
original.key,
);
this.files.get(albumID)!.set(id, {
id,
collectionID: albumID,
ownerID: USER_ID,
encryptedKey: opts.corruptKey ? corruptKey() : sealedKey.ciphertext,
keyDecryptionNonce: sealedKey.nonce,
metadata: {
encryptedData: toBase64(sealedMetadata.ciphertext),
decryptionHeader: toBase64(sealedMetadata.header),
},
file: { decryptionHeader: original.header },
thumbnail: { decryptionHeader: original.header },
updationTime: t,
});
this.moveAlbum(albumID, t);
}
// A deleted record stays on the server as a tombstone holding only its
// ID, its time and `isDeleted`.
deleteFile(albumID: number, id: number, t: number): void {
this.files
.get(albumID)!
.set(id, { id, updationTime: t, isDeleted: true });
this.moveAlbum(albumID, t);
}
deleteAlbum(id: number, t: number): void {
this.albums.set(id, { id, updationTime: t, isDeleted: true });
}
// The `fetch` the saved session's client is given.
readonly fetch = (async (input: string | URL | Request) => {
const url = new URL(input instanceof Request ? input.url : input);
const since = Number(url.searchParams.get("sinceTime"));
const changed = (rows: Iterable<Row>): Row[] =>
[...rows].filter((row) => row.updationTime > since);
switch (url.pathname) {
case "/collections/v2":
return json({ collections: changed(this.albums.values()) });
case "/collections/v2/diff": {
const albumID = Number(url.searchParams.get("collectionID"));
const rows = this.files.get(albumID)?.values() ?? [];
return json({ diff: changed(rows), hasMore: false });
}
case "/files/data/fetch":
// No file has ML data.
return json({ data: [] });
default: {
const fileID = Number(url.searchParams.get("fileID"));
const original = this.originals.get(fileID);
return original
? new Response(original.body)
: new Response("not found", { status: 404 });
}
}
}) as typeof globalThis.fetch;
private originalOf(id: number): {
key: Uint8Array;
header: string;
body: Uint8Array;
} {
const known = this.originals.get(id);
if (known) return known;
const key = sodium.crypto_secretbox_keygen();
const { header, ciphertext } = encryptBlob(
new TextEncoder().encode(`original of ${id}`),
key,
);
const original = { key, header: toBase64(header), body: ciphertext };
this.originals.set(id, original);
return original;
}
private moveAlbum(albumID: number, t: number): void {
this.albums.set(albumID, {
...this.albums.get(albumID)!,
updationTime: t,
});
}
}
// Album 1, "Trip", holds file 10, which decrypts, file 11, whose key does not,
// and file 12, whose metadata is not JSON. Album 2, "Family", holds file 20,
// but the album's own key does not decrypt. All of it as of time 1.
const brokenAccount = (): FakeEnte => {
const ente = new FakeEnte();
ente.putAlbum(1, "Trip", 1);
ente.putFile(1, 10, 1);
ente.putFile(1, 11, 1, { corruptKey: true });
ente.putFile(1, 12, 1, { metadata: "not json" });
ente.putAlbum(2, "Family", 1, true);
ente.putFile(2, 20, 1);
return ente;
};
// Collects everything written to it.
class Output {
text = "";
write(text: string): void {
this.text += text;
}
}
let root: string;
let stdout: Output;
let stderr: Output;
beforeAll(async () => {
await init();
await sodium.ready;
});
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "quak-undecryptable-"));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
// A context for one command, with fresh output, whose saved session talks to
// `ente`. Every run shares one cache directory, as runs of `quak` do.
const context = (ente: FakeEnte): CliContext => {
stdout = new Output();
stderr = new Output();
saveSession(join(root, "session"), ente.snapshot());
return {
stdout,
stderr,
sessionDir: join(root, "session"),
cacheDir: join(root, "cache"),
loadSession: (path) =>
loadSession(path, { fetch: ente.fetch, retry: { attempts: 1 } }),
login: async () => {
throw new Error("login not expected");
},
prompt: async () => {
throw new Error("prompt not expected");
},
promptSecret: async () => {
throw new Error("prompt not expected");
},
};
};
const backupDir = (): string => join(root, "backup");
// Runs `quak backup` into the backup directory and returns its exit code.
const backup = (ente: FakeEnte, opts: { json?: boolean } = {}) =>
backupCommand(context(ente), backupDir(), opts);
// The backup's `failures.json` entries by file ID, or {} when it has none.
const failures = (): Record<string, { error: string; attempts: number }> => {
const path = join(backupDir(), "failures.json");
return existsSync(path)
? JSON.parse(readFileSync(path, "utf-8")).files
: {};
};
// Whether file `id`'s original is at its save path in the backup.
const stored = (id: number): boolean =>
existsSync(join(backupDir(), DAY, `2026-03-01.${id}.jpg`));
describe("quak backup with records it cannot decrypt", () => {
it("backs up every other file, records each file it cannot decrypt in failures.json, names each such album, and exits 1", async () => {
expect(await backup(brokenAccount())).toBe(1);
expect(stored(10)).toBe(true);
// Without its album's key, file 20 cannot even be listed.
expect(stored(20)).toBe(false);
expect(failures()).toEqual({
"11": expect.objectContaining({
fileID: 11,
error: expect.stringMatching(
/^could not be decrypted: .*wrong secret key/,
),
}),
"12": expect.objectContaining({
fileID: 12,
error: expect.stringMatching(/^could not be decrypted: .*JSON/),
}),
});
expect(stderr.text).toMatch(
/^FAILED album 2: could not be decrypted: .*wrong secret key/m,
);
expect(stderr.text).toContain(" Failed: 2\n");
});
it("names each album it cannot decrypt in the --json result", async () => {
expect(await backup(brokenAccount(), { json: true })).toBe(1);
expect(JSON.parse(stdout.text)).toMatchObject({
downloaded: 1,
failed: 2,
undecryptableAlbums: [2],
});
});
it("still reports them on the next run, though the server does not send them again", async () => {
const ente = brokenAccount();
await backup(ente);
// Nothing changed on the server, so this run's refresh gets no rows at
// all. What it reports, it remembered from the first run.
expect(await backup(ente)).toBe(1);
expect(Object.keys(failures())).toEqual(["11", "12"]);
expect(failures()["11"]!.attempts).toBe(2);
expect(stderr.text).toMatch(/^FAILED album 2: could not be decrypted/m);
});
it("drops each once the server sends a copy that decrypts, and then exits 0", async () => {
const ente = brokenAccount();
await backup(ente);
// At time 2 files 11 and 12 and album 2 change on the server, and now
// each decrypts. Album 2 brings file 20 with it.
ente.putFile(1, 11, 2);
ente.putFile(1, 12, 2);
ente.putAlbum(2, "Family", 2);
expect(await backup(ente)).toBe(0);
expect([11, 12, 20].map(stored)).toEqual([true, true, true]);
expect(existsSync(join(backupDir(), "failures.json"))).toBe(false);
expect(stderr.text).not.toContain("FAILED");
});
it("drops each once the server deletes it, and then exits 0", async () => {
const ente = brokenAccount();
await backup(ente);
ente.deleteFile(1, 11, 2);
ente.deleteFile(1, 12, 2);
ente.deleteAlbum(2, 2);
expect(await backup(ente)).toBe(0);
expect(existsSync(join(backupDir(), "failures.json"))).toBe(false);
expect(stderr.text).not.toContain("FAILED");
});
it("backs up a file that does not decrypt in one album but does in another, and does not report it", async () => {
// File 30 is in albums 1 and 3, each holding its key sealed under the
// album's own key. In album 1 that is corrupt; in album 3 it is not,
// and one copy that decrypts is all the backup needs.
const ente = new FakeEnte();
ente.putAlbum(1, "Trip", 1);
ente.putFile(1, 30, 1, { corruptKey: true });
ente.putAlbum(3, "Favorites", 1);
ente.putFile(3, 30, 1);
expect(await backup(ente)).toBe(0);
expect(stored(30)).toBe(true);
expect(failures()).toEqual({});
});
it("backs up that file in a run limited to the album where it does not decrypt", async () => {
// The same account, backed up by `lib.backup()` limited to album 1,
// "Trip", as `quak backup` cannot be. File 30 is in Trip, so the run
// must store it, from its copy in album 3 that the run otherwise
// skips.
const ente = new FakeEnte();
ente.putAlbum(1, "Trip", 1);
ente.putFile(1, 30, 1, { corruptKey: true });
ente.putAlbum(3, "Favorites", 1);
ente.putFile(3, 30, 1);
const lib = await Library.open({
client: Client.fromJSON(ente.snapshot(), {
fetch: ente.fetch,
retry: { attempts: 1 },
}),
downloadDirectory: backupDir(),
cacheDirectory: join(root, "cache"),
precacheThumbnails: false,
precacheOriginals: false,
});
try {
const result = await lib.backup({
downloadDirectory: backupDir(),
onlyAlbumNames: ["Trip"],
});
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
} finally {
await lib.close();
}
expect(stored(30)).toBe(true);
expect(failures()).toEqual({});
});
});
describe("the read commands with records quak cannot decrypt", () => {
it("quak collections and quak files answer from what decrypts", async () => {
const ente = brokenAccount();
expect(await collectionsCommand(context(ente), {})).toBe(0);
expect(stdout.text).toBe("1\talbum\tTrip\n");
expect(await filesCommand(context(ente), { collection: "1" })).toBe(0);
expect(stdout.text).toBe("10\timage\tphoto-10.jpg\n");
});
});
+682 -1
View File
@@ -12,6 +12,7 @@
* collections/ * collections/
* <name>/<title> symlink to the original (rebuilt each run) * <name>/<title> symlink to the original (rebuilt each run)
* <name>.json per-collection metadata (rebuilt each run) * <name>.json per-collection metadata (rebuilt each run)
* account.json the account's email and user ID
* failures.json durable ledger of unresolved failures * failures.json durable ledger of unresolved failures
* *
* The properties that distinguish backup from a naive download loop, and that * The properties that distinguish backup from a naive download loop, and that
@@ -32,6 +33,7 @@
*/ */
import { import {
chmodSync,
existsSync, existsSync,
lstatSync, lstatSync,
mkdirSync, mkdirSync,
@@ -41,6 +43,7 @@ import {
readlinkSync, readlinkSync,
rmSync, rmSync,
symlinkSync, symlinkSync,
utimesSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
@@ -52,11 +55,17 @@ import { runBackup, type BackupLibrary } from "../../src/backup.js";
import { Library } from "../../src/library/index.js"; import { Library } from "../../src/library/index.js";
import type { ContentSource } from "../../src/library/content.js"; import type { ContentSource } from "../../src/library/content.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js";
import { extractImageMetadata } from "../../src/metadata-backup.js";
import type { MLData } from "../../src/mldata-fetch.js";
import type { Collection, EnteFile } from "../../src/model/types.js"; import type { Collection, EnteFile } from "../../src/model/types.js";
import { HEIC_WITH_EXIF } from "../exif-heic.js";
import { JPEG_WITH_EXIF } from "../exif-jpeg.js";
import { import {
asLivePhoto, asLivePhoto,
blake2b,
cdnSource, cdnSource,
IMAGE, IMAGE,
livePhotoHash,
livePhotoZip, livePhotoZip,
VIDEO, VIDEO,
} from "../live-photo.js"; } from "../live-photo.js";
@@ -823,7 +832,617 @@ describe("the refresh before a backup", () => {
); );
expect(source.originalCalls).toBe(0); expect(source.originalCalls).toBe(0);
expect(existsSync(outDir)).toBe(false); // The backup made the directory for its lock, and removed the lock.
expect(readdirSync(outDir)).toEqual([]);
await lib.close();
});
});
describe("the backup lock", () => {
const lockPath = (outDir: string): string => join(outDir, "backup.lock");
it("refuses a second backup of the directory while one runs", async () => {
await fillCache();
const client = new HeldClient();
const lib = await openLibrary(stubSource(), client);
const outDir = join(root, "backup");
// The first backup holds the lock once it starts its refresh, which
// `HeldClient` keeps from finishing.
let refreshing!: () => void;
const started = new Promise<void>((resolve) => {
refreshing = resolve;
});
const first = lib.backup({
downloadDirectory: outDir,
onProgress: (msg) => {
if (msg === "Refreshing library...") refreshing();
},
});
await started;
await expect(lib.backup({ downloadDirectory: outDir })).rejects.toThrow(
`another backup of ${outDir} is running`,
);
client.release();
expect((await first).failed).toBe(0);
await lib.close();
});
it("releases the lock after a backup succeeds", async () => {
const lib = await openLibrary(stubSource());
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
expect(existsSync(lockPath(outDir))).toBe(false);
// So the next backup of the directory runs.
expect((await lib.backup({ downloadDirectory: outDir })).skipped).toBe(
3,
);
await lib.close();
});
it("releases the lock after a backup fails", async () => {
const lib = await openLibrary(stubSource(), new FailingClient());
const outDir = join(root, "backup");
await expect(lib.backup({ downloadDirectory: outDir })).rejects.toThrow(
"HTTP 401 from server",
);
expect(existsSync(lockPath(outDir))).toBe(false);
await lib.close();
});
it("takes over a lock left by a run that was killed", async () => {
const outDir = join(root, "backup");
// A killed run's lock, last touched a minute ago.
mkdirSync(lockPath(outDir), { recursive: true });
const minuteAgo = new Date(Date.now() - 60_000);
utimesSync(lockPath(outDir), minuteAgo, minuteAgo);
const lib = await openLibrary(stubSource());
const result = await lib.backup({ downloadDirectory: outDir });
expect(result.downloaded).toBe(3);
expect(existsSync(lockPath(outDir))).toBe(false);
await lib.close();
});
});
// Also serves ML data: file 100 has `ML_PAYLOAD`, the other two have none.
// While `mlError` is set, every ML data request fails with it.
const ML_PAYLOAD = { face: { faces: [] }, clip: { embedding: [0.5, 0.25] } };
class MLClient extends MockClient {
mlError?: string;
async fetchMLData(args: {
fileIDs: number[];
}): Promise<Map<number, MLData>> {
if (this.mlError) throw new Error(this.mlError);
const result = new Map<number, MLData>();
if (args.fileIDs.includes(100)) result.set(100, ML_PAYLOAD);
return result;
}
}
// The JSON the backup in `outDir` wrote beside the original of `fileID`.
const fileJSON = (outDir: string, fileID: number): Record<string, unknown> =>
JSON.parse(readFileSync(saved(outDir, `${fileID}.json`), "utf-8"));
describe("ML data in each file's JSON", () => {
it("writes a file's ML data, and no ML field for a file that has none", async () => {
const lib = await openLibrary(stubSource(), new MLClient());
const outDir = join(root, "backup");
const result = await lib.backup({ downloadDirectory: outDir });
expect(result.failed).toBe(0);
expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD);
expect(fileJSON(outDir, 101)).not.toHaveProperty("mlData");
expect(fileJSON(outDir, 101)).not.toHaveProperty("mlDataError");
await lib.close();
});
it("gives each file with no cached ML data the reason when the fetch fails, and fails the run", async () => {
const client = new MLClient();
const outDir = join(root, "backup");
const first = await openLibrary(stubSource(), client);
await first.backup({ downloadDirectory: outDir });
await first.close();
client.mlError = "HTTP 503 from server";
const lib = await openLibrary(stubSource(), client);
const result = await lib.backup({ downloadDirectory: outDir });
// File 100's ML data was cached by the first run and is kept.
expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD);
expect(fileJSON(outDir, 100)).not.toHaveProperty("mlDataError");
for (const fileID of [101, 200]) {
expect(fileJSON(outDir, fileID).mlDataError).toBe(
"HTTP 503 from server",
);
}
expect(result.failed).toBe(2);
expect(result.errors.map((e) => [e.fileID, e.error])).toEqual([
[101, "ML data: HTTP 503 from server"],
[200, "ML data: HTTP 503 from server"],
]);
expect(Object.keys(readLedger(outDir).files)).toEqual(["101", "200"]);
await lib.close();
});
it("writes the ML data on the run after a failed fetch", async () => {
const client = new MLClient();
client.mlError = "HTTP 503 from server";
const outDir = join(root, "backup");
const first = await openLibrary(stubSource(), client);
expect((await first.backup({ downloadDirectory: outDir })).failed).toBe(
3,
);
expect(fileJSON(outDir, 100)).not.toHaveProperty("mlData");
await first.close();
client.mlError = undefined;
const lib = await openLibrary(stubSource(), client);
const result = await lib.backup({ downloadDirectory: outDir });
expect(result.failed).toBe(0);
expect(result.skipped).toBe(3);
expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD);
for (const fileID of [100, 101, 200]) {
expect(fileJSON(outDir, fileID)).not.toHaveProperty("mlDataError");
}
expect(existsSync(join(outDir, "failures.json"))).toBe(false);
await lib.close();
});
});
// The fields `RecordsClient` gives the Vacation album: shared by another
// account, with all three layers of magic metadata.
const SHARED_ALBUM = {
ownerID: 7,
isShared: true,
updationTime: 1700,
magicMetadata: { visibility: 0 },
pubMagicMetadata: { coverID: 100 },
sharedMagicMetadata: { visibility: 2 },
};
// Serves the Vacation album with `SHARED_ALBUM`'s fields, and each file with
// the update time 5000 + its ID.
class RecordsClient extends MockClient {
override async collectionsSince(): Promise<CollectionsPage> {
const page = await super.collectionsSince();
return {
...page,
collections: page.collections.map((c) =>
c.id === 1 ? { ...c, ...SHARED_ALBUM } : c,
),
};
}
override async filesSince(args: {
collectionID: number;
}): Promise<FilesPage> {
const page = await super.filesSince(args);
return {
...page,
files: page.files.map((f) => ({
...f,
updationTime: 5000 + f.id,
})),
};
}
}
// The JSON the backup in `outDir` wrote for the album named `name`.
const albumJSON = (outDir: string, name: string): Record<string, unknown> =>
JSON.parse(
readFileSync(join(outDir, "collections", `${name}.json`), "utf-8"),
);
describe("account and album records", () => {
it("writes account.json with the account's email and user ID", async () => {
const lib = await openLibrary(stubSource());
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
expect(
JSON.parse(readFileSync(join(outDir, "account.json"), "utf-8")),
).toEqual({ email: "backup@example.com", userID: USER_ID });
await lib.close();
});
it("writes each album's owner, sharing, update time and magic metadata into its JSON", async () => {
const lib = await openLibrary(stubSource(), new RecordsClient());
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
expect(albumJSON(outDir, "Vacation")).toEqual({
id: 1,
name: "Vacation",
type: "album",
...SHARED_ALBUM,
files: [
{ id: 100, metadata: file(100, 1, "beach.jpg").metadata },
{ id: 101, metadata: file(101, 1, "sunset.jpg").metadata },
],
});
// An album with no magic metadata gets no magic metadata fields.
expect(albumJSON(outDir, "Work")).toEqual({
id: 2,
name: "Work",
type: "album",
ownerID: USER_ID,
isShared: false,
updationTime: 1,
files: [
{ id: 200, metadata: file(200, 2, "diagram.png").metadata },
],
});
await lib.close();
});
it("writes each file's update time into its JSON", async () => {
const lib = await openLibrary(stubSource(), new RecordsClient());
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
for (const fileID of [100, 101, 200]) {
expect(fileJSON(outDir, fileID).updationTime).toBe(5000 + fileID);
}
await lib.close();
});
});
// What a file's JSON holds as `imageMetadata` for an original of `bytes`.
const imageMetadataOf = (bytes: Uint8Array): unknown =>
JSON.parse(JSON.stringify(extractImageMetadata(bytes)));
describe("image metadata in each file's JSON", () => {
// Serves `files` in the Vacation album and nothing in Work.
class FilesClient extends MockClient {
constructor(private readonly files: EnteFile[]) {
super();
}
override async filesSince(args: {
collectionID: number;
}): Promise<FilesPage> {
return {
files: args.collectionID === 1 ? this.files : [],
deleted: [],
cursor: 1,
};
}
}
// Writes `originals.get(fileID)` as each file's original, looked up at
// each fetch, so a test can change it between runs.
const bytesSource = (
originals: Map<number, Uint8Array>,
): ContentSource => ({
original: async ({ file: f, destination }) => {
const bytes = originals.get(f.id)!;
writeFileSync(destination, bytes);
return { bytesWritten: bytes.length };
},
thumbnail: async () => {
throw new Error("no thumbnails in this source");
},
});
// A JPEG, a HEIC, a video, and a file holding no image metadata. The
// video's bytes are the JPEG's, so reading it would find EXIF.
const clip = file(602, 1, "clip.mov");
const files = [
file(600, 1, "photo.jpg"),
file(601, 1, "photo.heic"),
{ ...clip, metadata: { ...clip.metadata, fileType: "video" as const } },
file(603, 1, "notes.png"),
];
const originals = (): Map<number, Uint8Array> =>
new Map([
[600, JPEG_WITH_EXIF],
[601, HEIC_WITH_EXIF],
[602, JPEG_WITH_EXIF],
[603, new TextEncoder().encode("not an image")],
]);
const open = (bytes = originals()): Promise<Library> =>
openLibrary(bytesSource(bytes), new FilesClient(files));
// Rewrite the JSON of `fileID` without its image metadata, as an earlier
// version of quak wrote it.
const dropImageMetadata = (outDir: string, fileID: number): void => {
const json = fileJSON(outDir, fileID);
delete json.imageMetadata;
writeFileSync(saved(outDir, `${fileID}.json`), JSON.stringify(json));
};
it("writes each new original's image metadata, and none for a video", async () => {
const lib = await open();
const outDir = join(root, "backup");
const result = await lib.backup({ downloadDirectory: outDir });
expect(result).toMatchObject({ downloaded: 4, failed: 0 });
expect(fileJSON(outDir, 600).imageMetadata).toEqual(
imageMetadataOf(JPEG_WITH_EXIF),
);
expect(fileJSON(outDir, 601).imageMetadata).toEqual(
imageMetadataOf(HEIC_WITH_EXIF),
);
expect(fileJSON(outDir, 602)).not.toHaveProperty("imageMetadata");
expect(fileJSON(outDir, 602)).not.toHaveProperty("imageMetadataError");
// Empty, so that the next run does not read it again.
expect(fileJSON(outDir, 603).imageMetadata).toEqual({});
await lib.close();
});
it("reads a live photo's image", async () => {
const { file: live, body } = await asLivePhoto(
file(500, 1, "IMG_0500.HEIC"),
livePhotoZip({ "image.heic": HEIC_WITH_EXIF, "video.mov": VIDEO }),
livePhotoHash(HEIC_WITH_EXIF, VIDEO),
);
const lib = await openLibrary(
cdnSource(new Map([[500, body]])),
new FilesClient([live]),
);
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
expect(fileJSON(outDir, 500).imageMetadata).toEqual(
imageMetadataOf(HEIC_WITH_EXIF),
);
await lib.close();
});
it("keeps the image metadata of an original stored by an earlier run without reading it again", async () => {
const lib = await open();
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
// A read of the original now would give the HEIC's.
writeFileSync(saved(outDir, "600.jpg"), HEIC_WITH_EXIF);
const second = await lib.backup({ downloadDirectory: outDir });
expect(second).toMatchObject({ downloaded: 0, failed: 0 });
expect(fileJSON(outDir, 600).imageMetadata).toEqual(
imageMetadataOf(JPEG_WITH_EXIF),
);
await lib.close();
});
it("reads an original stored by an earlier version, whose JSON has no image metadata", async () => {
const lib = await open();
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
dropImageMetadata(outDir, 600);
const second = await lib.backup({ downloadDirectory: outDir });
expect(second).toMatchObject({ downloaded: 0, failed: 0 });
expect(fileJSON(outDir, 600).imageMetadata).toEqual(
imageMetadataOf(JPEG_WITH_EXIF),
);
await lib.close();
});
it("reads an original the run stores again, though its JSON has image metadata", async () => {
const bytes = originals();
const lib = await open(bytes);
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
rmSync(saved(outDir, "600.jpg"));
bytes.set(600, HEIC_WITH_EXIF);
const second = await lib.backup({ downloadDirectory: outDir });
expect(second).toMatchObject({ downloaded: 1, failed: 0 });
expect(fileJSON(outDir, 600).imageMetadata).toEqual(
imageMetadataOf(HEIC_WITH_EXIF),
);
await lib.close();
});
// Root ignores file permissions, so this fails when run as root. The
// `test` phase of the `Dockerfile` runs as the `node` user.
it("gives the reason an original could not be read, failing neither the file nor the run", async () => {
const lib = await open();
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
dropImageMetadata(outDir, 600);
const original = saved(outDir, "600.jpg");
chmodSync(original, 0o000);
const second = await lib
.backup({ downloadDirectory: outDir })
.finally(() => chmodSync(original, 0o600));
expect(second).toMatchObject({ failed: 0, errors: [] });
expect(existsSync(join(outDir, "failures.json"))).toBe(false);
expect(fileJSON(outDir, 600)).not.toHaveProperty("imageMetadata");
expect(fileJSON(outDir, 600).imageMetadataError).toMatch(/EACCES/);
await lib.close();
});
});
// With `verify`, each original already stored is hashed, and one that does not
// match the content hash its metadata records is downloaded again.
describe("backup with verify", () => {
// MockClient's files, each recording the hash of what `stubSource` writes
// for it, except diagram.png (200), which records none.
class HashedClient extends MockClient {
override async filesSince(args: {
collectionID: number;
}): Promise<FilesPage> {
const page = await super.filesSince(args);
const files = page.files.map((f) =>
f.id === 200
? f
: {
...f,
metadata: {
...f.metadata,
hash: blake2b(Buffer.alloc(SIZE_BY_ID[f.id]!)),
},
},
);
return { ...page, files };
}
}
// A backup of the account in `root/backup`, the library that made it, and
// the source it fetched from.
const backedUp = async (): Promise<{
lib: Library;
source: StubSource;
outDir: string;
}> => {
const source = stubSource();
const lib = await openLibrary(source, new HashedClient());
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
return { lib, source, outDir };
};
// What `stubSource` writes for beach.jpg (100), and other bytes of the
// same length.
const good = Buffer.alloc(SIZE_BY_ID[100]!);
const corrupt = Buffer.alloc(SIZE_BY_ID[100]!, 1);
it("leaves an original that matches its hash, and one with no hash, as they are", async () => {
const { lib, source, outDir } = await backedUp();
const calls = source.originalCalls;
const result = await lib.backup({
downloadDirectory: outDir,
verify: true,
});
expect(result).toMatchObject({
downloaded: 0,
skipped: 3,
verified: 2,
mismatched: 0,
unchecked: 1,
failed: 0,
});
expect(source.originalCalls).toBe(calls);
await lib.close();
});
it("downloads again an original that does not match its hash", async () => {
const { lib, outDir } = await backedUp();
writeFileSync(saved(outDir, "100.jpg"), corrupt);
const log: string[] = [];
const result = await lib.backup({
downloadDirectory: outDir,
verify: true,
onProgress: (msg) => log.push(msg),
});
expect(result).toMatchObject({
downloaded: 1,
skipped: 2,
verified: 1,
mismatched: 1,
unchecked: 1,
failed: 0,
});
expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(good);
expect(log.filter((msg) => msg.startsWith("MISMATCH"))).toEqual([
"MISMATCH original beach.jpg (100): its bytes do not match its content hash",
]);
await lib.close();
});
it("records a failed download in failures.json, with the original removed", async () => {
const { lib, source, outDir } = await backedUp();
writeFileSync(saved(outDir, "100.jpg"), corrupt);
source.failID = 100;
const result = await lib.backup({
downloadDirectory: outDir,
verify: true,
});
expect(result).toMatchObject({
downloaded: 0,
mismatched: 1,
failed: 1,
});
expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]);
expect(existsSync(saved(outDir, "100.jpg"))).toBe(false);
await lib.close();
});
it("records in failures.json an original the cache puts back with the same bad bytes", async () => {
const lib = await openLibrary(stubSource(), new HashedClient());
const outDir = join(root, "backup");
// The cache holds a bad copy, and a backup copies an original the
// cache holds to its save path.
const cached = await lib.photos.byID({ fileID: 100 })!.original();
writeFileSync(cached.path, corrupt);
await lib.backup({ downloadDirectory: outDir });
const result = await lib.backup({
downloadDirectory: outDir,
verify: true,
});
expect(result).toMatchObject({
downloaded: 0,
mismatched: 1,
failed: 1,
});
expect(result.errors.map((e) => e.error)).toEqual([
"the original put back does not match its content hash either",
]);
expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]);
expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(corrupt);
await lib.close();
});
it("hashes nothing without verify", async () => {
const { lib, outDir } = await backedUp();
writeFileSync(saved(outDir, "100.jpg"), corrupt);
const result = await lib.backup({ downloadDirectory: outDir });
expect(result).toMatchObject({
downloaded: 0,
skipped: 3,
verified: 0,
mismatched: 0,
unchecked: 0,
failed: 0,
});
expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(corrupt);
await lib.close();
});
// Root ignores file permissions, so this fails when run as root. The
// `test` phase of the `Dockerfile` runs as the `node` user.
it("records an original it cannot read as failed, and leaves it", async () => {
const { lib, outDir } = await backedUp();
const original = saved(outDir, "100.jpg");
chmodSync(original, 0o000);
const result = await lib
.backup({ downloadDirectory: outDir, verify: true })
.finally(() => chmodSync(original, 0o600));
expect(result).toMatchObject({ downloaded: 0, verified: 1, failed: 1 });
expect(result.errors.map((e) => e.fileID)).toEqual([100]);
expect(result.errors[0]!.error).toMatch(/EACCES/);
expect(readFileSync(original)).toEqual(good);
await lib.close(); await lib.close();
}); });
}); });
@@ -863,6 +1482,7 @@ describe("backup album folders", () => {
listCollections: () => albums.map((a) => a.collection), listCollections: () => albums.map((a) => a.collection),
listFiles: (id) => listFiles: (id) =>
albums.find((a) => a.collection.id === id)?.files ?? [], albums.find((a) => a.collection.id === id)?.files ?? [],
undecryptable: () => [],
original: async (fileID) => { original: async (fileID) => {
const path = join(root, `source-${fileID}`); const path = join(root, `source-${fileID}`);
writeFileSync(path, `original ${fileID}`); writeFileSync(path, `original ${fileID}`);
@@ -871,6 +1491,9 @@ describe("backup album folders", () => {
thumbnail: async () => { thumbnail: async () => {
throw new Error("no thumbnails in this stand-in"); throw new Error("no thumbnails in this stand-in");
}, },
fetchMLData: async () => {},
mlData: async () => undefined,
whoami: () => ({ email: "backup@example.com", userID: USER_ID }),
}); });
const albumID = (outDir: string, jsonName: string): number => const albumID = (outDir: string, jsonName: string): number =>
@@ -1320,6 +1943,64 @@ describe("backup of live photos", () => {
}, },
); );
it("verifies a live photo's image and video together, and downloads it again when one does not match", async () => {
const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"),
);
const lib = await open([live], new Map([[500, body]]));
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
const good = await lib.backup({
downloadDirectory: outDir,
verify: true,
});
expect(good).toMatchObject({ skipped: 1, verified: 1, mismatched: 0 });
const video = saved(outDir, "500.mov");
writeFileSync(video, "another few seconds of video");
const bad = await lib.backup({
downloadDirectory: outDir,
verify: true,
});
expect(bad).toMatchObject({
downloaded: 1,
verified: 0,
mismatched: 1,
failed: 0,
});
expect(readFileSync(saved(outDir, "500.heic"))).toEqual(
Buffer.from(IMAGE),
);
expect(readFileSync(video)).toEqual(Buffer.from(VIDEO));
expect(tree(outDir)).toEqual(linked);
await lib.close();
});
it("removes both files of a live photo that does not match its hash when downloading it again fails", async () => {
const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"),
);
const bodies = new Map([[500, body]]);
const lib = await open([live], bodies);
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
writeFileSync(saved(outDir, "500.mov"), "another few seconds of video");
bodies.delete(500);
const result = await lib.backup({
downloadDirectory: outDir,
verify: true,
});
expect(result).toMatchObject({ mismatched: 1, failed: 1 });
expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]);
expect(existsSync(saved(outDir, "500.heic"))).toBe(false);
expect(existsSync(saved(outDir, "500.mov"))).toBe(false);
await lib.close();
});
it("serves a live photo the backup stored to a library reading the backup", async () => { it("serves a live photo the backup stored to a library reading the backup", async () => {
const { file: live, body } = await asLivePhoto( const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"), file(500, 10, "IMG_0500.HEIC"),
+71
View File
@@ -0,0 +1,71 @@
/**
* Tests for the retry options `bin/quak.ts` loads the saved session with:
* `quak backup` gets the unattended ones, every other command the default.
*
* Each test runs `bin/quak.ts`, as the smoke test does, with its session loader
* replaced by one that records the options it is given and reports no session,
* so the command stops before it makes any request.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import type { ApiClientOptions } from "../../src/api/client.js";
import { UNATTENDED_RETRY_OPTIONS } from "../../src/retry.js";
const loaded = vi.hoisted(() => [] as (ApiClientOptions | undefined)[]);
vi.mock("../../src/cli-session.js", () => ({
loadSession: (_path: string, apiOptions?: ApiClientOptions) => {
loaded.push(apiOptions);
return null;
},
}));
const argv = process.argv;
const dir = mkdtempSync(join(tmpdir(), "quak-bin-test-"));
// Run `quak <args>` to completion. The "Not logged in" message and the exit
// are swallowed.
const quak = async (...args: string[]): Promise<void> => {
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
process.argv = ["node", "quak", ...args];
vi.resetModules();
await import("../../bin/quak.js");
};
afterEach(() => {
process.argv = argv;
loaded.length = 0;
vi.restoreAllMocks();
});
afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});
describe("bin/quak.ts session loading", () => {
it("loads the session for backup with the unattended retry options", async () => {
await quak("backup", dir);
// `vi.resetModules()` gave `bin/quak.ts` its own copy of
// `src/retry.ts`, whose `sleep` is a different function, so the
// options are compared by their numbers.
const { attempts, baseDelayMs, maxDelayMs } = UNATTENDED_RETRY_OPTIONS;
expect(loaded).toEqual([
{
retry: expect.objectContaining({
attempts,
baseDelayMs,
maxDelayMs,
}),
},
]);
});
it("loads the session for another command with the default options", async () => {
await quak("collections");
expect(loaded).toEqual([undefined]);
});
});
+298 -27
View File
@@ -18,6 +18,7 @@ import {
readFileSync, readFileSync,
rmSync, rmSync,
statSync, statSync,
utimesSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
@@ -52,13 +53,14 @@ import {
import { run } from "../../src/cli-run.js"; import { run } from "../../src/cli-run.js";
import { loadSession } from "../../src/cli-session.js"; import { loadSession } from "../../src/cli-session.js";
import type { Client, ClientSnapshot, LoginOptions } from "../../src/client.js"; import type { Client, ClientSnapshot, LoginOptions } from "../../src/client.js";
import type { ContentSource } from "../../src/library/content.js"; import { savePath, type ContentSource } from "../../src/library/content.js";
import type { Collection, EnteFile } from "../../src/model/types.js"; import type { Collection, EnteFile } from "../../src/model/types.js";
import { init, toBase64 } from "../../src/crypto/index.js"; import { init, toBase64 } from "../../src/crypto/index.js";
import { defaultCacheDirectory } from "../../src/library/index.js"; import { defaultCacheDirectory } from "../../src/library/index.js";
import { HEIC_WITH_EXIF } from "../exif-heic.js"; import { HEIC_WITH_EXIF } from "../exif-heic.js";
import { import {
asLivePhoto, asLivePhoto,
blake2b,
cdnSource, cdnSource,
IMAGE, IMAGE,
livePhotoHash, livePhotoHash,
@@ -230,20 +232,30 @@ describe("session file", () => {
expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(snapshot); expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(snapshot);
}); });
it("a missing session exits 1 with 'Not logged in'", async () => { it("a missing session exits 3 with 'Not logged in' from every command that needs one", async () => {
const ctx = { ...context(), loadSession }; const ctx = { ...context(), loadSession };
expect(await whoamiCommand(ctx)).toBe(1); const dir = join(root, "backup");
expect(stderr.text).toBe( expect(await whoamiCommand(ctx)).toBe(3);
expect(await collectionsCommand(ctx, {})).toBe(3);
expect(await filesCommand(ctx, { collection: "1" })).toBe(3);
expect(await getCommand(ctx, "100", {})).toBe(3);
expect(await getThumbCommand(ctx, "100", {})).toBe(3);
expect(await backupMetadataCommand(ctx, dir, {})).toBe(3);
expect(await backupCommand(ctx, dir, {})).toBe(3);
expect(await listMissingThumbnailsCommand(ctx, {})).toBe(3);
expect(await fixMissingThumbnailsCommand(ctx, {})).toBe(3);
const notLoggedIn =
`Not logged in. Run "quak login" first.\n` + `Not logged in. Run "quak login" first.\n` +
`Session file: ${join(ctx.sessionDir, "session.json")}\n`, `Session file: ${join(ctx.sessionDir, "session.json")}\n`;
); expect(stderr.text).toBe(notLoggedIn.repeat(9));
expect(stdout.text).toBe(""); expect(stdout.text).toBe("");
expect(existsSync(dir)).toBe(false);
}); });
it("a corrupt session exits 1 and says it is corrupt", async () => { it("a corrupt session exits 3 and says it is corrupt", async () => {
const ctx = { ...context(), loadSession }; const ctx = { ...context(), loadSession };
saveSession(ctx.sessionDir, snapshot); saveSession(ctx.sessionDir, snapshot);
expect(await collectionsCommand(ctx, {})).toBe(1); expect(await collectionsCommand(ctx, {})).toBe(3);
expect(stderr.text).toContain("is corrupt"); expect(stderr.text).toContain("is corrupt");
expect(stderr.text).toContain( expect(stderr.text).toContain(
`Run "quak logout" and then "quak login" to replace it.\n`, `Run "quak logout" and then "quak login" to replace it.\n`,
@@ -499,11 +511,6 @@ describe("files", () => {
expect(await filesCommand(context(), { collection: "9" })).toBe(1); expect(await filesCommand(context(), { collection: "9" })).toBe(1);
expect(stderr.text).toBe("Collection 9 not found\n"); expect(stderr.text).toBe("Collection 9 not found\n");
}); });
it("exits 1 for a collection ID that is not a number", async () => {
expect(await filesCommand(context(), { collection: "abc" })).toBe(1);
expect(stderr.text).toBe("Invalid collection ID\n");
});
}); });
describe("get and get-thumb", () => { describe("get and get-thumb", () => {
@@ -534,12 +541,6 @@ describe("get and get-thumb", () => {
expect(stderr.text).toBe("File 999 not found\n"); expect(stderr.text).toBe("File 999 not found\n");
expect(existsSync(out)).toBe(false); expect(existsSync(out)).toBe(false);
}); });
it("both exit 1 for a file ID that is not a number", async () => {
expect(await getCommand(context(), "abc", {})).toBe(1);
expect(await getThumbCommand(context(), "abc", {})).toBe(1);
expect(stderr.text).toBe("Invalid file ID\nInvalid file ID\n");
});
}); });
// A live photo, which Ente stores as one ZIP, is written as its image and its // A live photo, which Ente stores as one ZIP, is written as its image and its
@@ -693,6 +694,7 @@ describe("backup", () => {
" Failed: 0\n", " Failed: 0\n",
); );
expect(stdout.text).toBe(""); expect(stdout.text).toBe("");
expect(existsSync(join(dir, "backup.lock"))).toBe(false);
}); });
// The backup opens its library with the precache off: it fetches the // The backup opens its library with the precache off: it fetches the
@@ -732,15 +734,83 @@ describe("backup", () => {
expect(stderr.text).toBe("Starting backup...\n"); expect(stderr.text).toBe("Starting backup...\n");
}); });
it("exits 1 with the error on one line when the refresh fails", async () => { it("--verify downloads again an original that does not match its hash, prints the counts, and exits 0", async () => {
// Each file records the hash of the original the fake writes for it.
const client = { const client = {
...fakeClient(), ...fakeClient(),
collectionsSince: async () => { filesSince: async (args: { collectionID: number }) => ({
throw new Error("HTTP 401 from server"); files: (FILES[args.collectionID] ?? []).map((f) => ({
...f,
metadata: {
...f.metadata,
hash: blake2b(Buffer.alloc(7, f.id & 0xff)),
},
})),
deleted: [],
cursor: 1,
}),
} as unknown as Client;
const ctx = context(client);
const dir = join(root, "backup");
expect(await backupCommand(ctx, dir, {})).toBe(0);
writeFileSync(savePath(dir, FILES[1]![0]!), "corrupt");
expect(await backupCommand(ctx, dir, { verify: true })).toBe(0);
expect(stderr.text).toContain(
"MISMATCH original beach.jpg (100): its bytes do not match its content hash\n",
);
expect(stderr.text).toContain(
" Downloaded: 1\n" +
" Skipped: 2\n" +
" Verified: 2\n" +
" Mismatched: 1\n" +
" Unchecked: 0\n" +
" Failed: 0\n",
);
});
it("--verify --json adds the verified, mismatched and unchecked counts", async () => {
const dir = join(root, "backup");
expect(await backupCommand(context(), dir, {})).toBe(0);
const code = await backupCommand(context(), dir, {
verify: true,
json: true,
});
expect(code).toBe(0);
expect(JSON.parse(stdout.text)).toMatchObject({
skipped: 3,
verified: 0,
mismatched: 0,
unchecked: 3,
failed: 0,
});
});
it("exits 1 and lists each file when the ML data fetch fails", async () => {
const client = {
...fakeClient(),
fetchMLData: async () => {
throw new Error("HTTP 503 from server");
}, },
} as unknown as Client; } as unknown as Client;
const dir = join(root, "backup"); expect(
// Through `run`, as `bin/quak.ts` does, which prints a thrown error. await backupCommand(context(client), join(root, "backup"), {}),
).toBe(1);
expect(stderr.text).toContain(" Failed: 3\n");
expect(stderr.text).toContain(
" [Vacation] beach.jpg (id 100): ML data: HTTP 503 from server\n",
);
});
// Runs `backup` through `run`, as `bin/quak.ts` does, which prints a thrown
// error; returns the exit code and what `run` printed.
const backupThroughRun = async (
ctx: CliContext,
dir: string,
): Promise<{ code: number; runText: string }> => {
const runStderr = new PassThrough(); const runStderr = new PassThrough();
let runText = ""; let runText = "";
runStderr.on("data", (chunk: Buffer) => { runStderr.on("data", (chunk: Buffer) => {
@@ -748,16 +818,90 @@ describe("backup", () => {
}); });
const code = await new Promise<number>((resolve) => { const code = await new Promise<number>((resolve) => {
void run( void run(
backupCommand(context(client), dir, {}), backupCommand(ctx, dir, {}),
new PassThrough(), new PassThrough(),
runStderr, runStderr,
resolve, resolve,
); );
}); });
return { code, runText };
};
it("exits 1 with the error on one line when the refresh fails", async () => {
const client = {
...fakeClient(),
collectionsSince: async () => {
throw new Error("HTTP 503 from server");
},
} as unknown as Client;
const dir = join(root, "backup");
const { code, runText } = await backupThroughRun(context(client), dir);
expect(code).toBe(1); expect(code).toBe(1);
expect(runText).toBe("quak: HTTP 401 from server\n"); expect(runText).toBe("quak: HTTP 503 from server\n");
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n"); expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
expect(existsSync(dir)).toBe(false); expect(readdirSync(dir)).toEqual([]);
});
// A real saved session, read back by `loadSession`, whose server answers
// every request with 401, as it does once it no longer accepts the token.
// The context's prompts throw, so a prompt would end the run with another
// line and exit 1.
it("exits 3 with one line saying to log in again when the server answers 401", async () => {
const key = toBase64(new Uint8Array(32));
saveSession(join(root, "session"), {
email: "cli@example.com",
userID: USER_ID,
token: "expired",
masterKey: key,
secretKey: key,
publicKey: key,
});
const unauthorized = async (): Promise<Response> =>
new Response(null, { status: 401 });
const ctx = {
...context(),
loadSession: (path: string) =>
loadSession(path, { fetch: unauthorized }),
};
const dir = join(root, "backup");
const { code, runText } = await backupThroughRun(ctx, dir);
expect(code).toBe(3);
expect(runText).toBe(
`quak: the saved session is no longer valid; run "quak login"\n`,
);
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
expect(readdirSync(dir)).toEqual([]);
});
it("exits 2 with one line naming the directory, sending no request, while another backup of it runs", async () => {
const dir = join(root, "backup");
// The lock another backup holds. Its modification time is set an hour
// ahead, so it stays current however long this test takes.
const lock = join(dir, "backup.lock");
mkdirSync(lock, { recursive: true });
const hourAhead = new Date(Date.now() + 3_600_000);
utimesSync(lock, hourAhead, hourAhead);
// A client whose refresh never finishes, so a run that started one
// before it exits would never return.
let requests = 0;
const never = (): Promise<never> => {
requests++;
return new Promise(() => {});
};
const client = {
...fakeClient(),
collectionsSince: never,
filesSince: never,
} as unknown as Client;
expect(await backupCommand(context(client), dir, {})).toBe(2);
expect(requests).toBe(0);
expect(stderr.text).toBe(
`Starting backup...\nquak: another backup of ${dir} is running\n`,
);
expect(readdirSync(dir)).toEqual(["backup.lock"]);
// Opening the library would have made its cache directory.
expect(existsSync(join(root, "cache"))).toBe(false);
}); });
}); });
@@ -880,3 +1024,130 @@ describe("backup-metadata and the thumbnail helpers refresh first", () => {
expect(stdout.text).toBe(""); expect(stdout.text).toBe("");
}); });
}); });
// `files --collection`, `get`, `get-thumb` and `helper fix-missing-thumbnails
// --file` take an ID only when it is written in decimal digits and is a safe
// integer. `Number()` alone reads `0x10` as 16, `1e3` as 1000 and an empty
// string as 0, so the command would act on a file the user never named.
// Anything else is refused with one line naming it, and exit 1, before any
// request.
//
// These run on a real client, read back from a saved session, over a fake Ente
// server holding an empty account, which records each request it receives. With
// a good ID the command goes on to ask the server, and finds nothing there.
describe("an ID given on the command line", () => {
const NOT_IDS = [
"0x10",
"1e3",
"1.5",
"-1",
"",
"abc",
// One past Number.MAX_SAFE_INTEGER: from here on a number cannot
// hold every integer, so two IDs could read as one.
"9007199254740992",
];
const requests: Request[] = [];
const emptyAccount = (): CliContext => {
const ctx = context();
const key = toBase64(new Uint8Array(32));
saveSession(ctx.sessionDir, {
email: "cli@example.com",
userID: USER_ID,
token: "token",
masterKey: key,
secretKey: key,
publicKey: key,
});
return {
...ctx,
loadSession: (path) =>
loadSession(path, {
fetch: async (url, init) => {
requests.push(new Request(url, init));
return new Response(
JSON.stringify({ collections: [] }),
{ headers: { "content-type": "application/json" } },
);
},
}),
};
};
beforeEach(() => {
requests.length = 0;
});
it("files looks up a decimal collection ID", async () => {
const ctx = emptyAccount();
expect(await filesCommand(ctx, { collection: "1" })).toBe(1);
expect(stderr.text).toBe("Collection 1 not found\n");
expect(requests).not.toHaveLength(0);
});
it.each(NOT_IDS)(
"files refuses --collection %j before any request",
async (arg) => {
const ctx = emptyAccount();
expect(await filesCommand(ctx, { collection: arg })).toBe(1);
expect(stderr.text).toBe(`Invalid collection ID: ${arg}\n`);
expect(stdout.text).toBe("");
expect(requests).toHaveLength(0);
},
);
it("get looks up a decimal file ID", async () => {
expect(await getCommand(emptyAccount(), "100", {})).toBe(1);
expect(stderr.text).toBe("File 100 not found\n");
expect(requests).not.toHaveLength(0);
});
it.each(NOT_IDS)("get refuses %j before any request", async (arg) => {
expect(await getCommand(emptyAccount(), arg, {})).toBe(1);
expect(stderr.text).toBe(`Invalid file ID: ${arg}\n`);
expect(stdout.text).toBe("");
expect(requests).toHaveLength(0);
});
it("get-thumb looks up a decimal file ID", async () => {
expect(await getThumbCommand(emptyAccount(), "100", {})).toBe(1);
expect(stderr.text).toBe("File 100 not found\n");
expect(requests).not.toHaveLength(0);
});
it.each(NOT_IDS)("get-thumb refuses %j before any request", async (arg) => {
expect(await getThumbCommand(emptyAccount(), arg, {})).toBe(1);
expect(stderr.text).toBe(`Invalid file ID: ${arg}\n`);
expect(stdout.text).toBe("");
expect(requests).toHaveLength(0);
});
// The fixer reports each file it was given; on this account neither is
// found, which is a failure, so it exits 1.
it("fix-missing-thumbnails looks up each decimal --file ID", async () => {
const ctx = emptyAccount();
const opts = { file: ["100", "200"], json: true };
expect(await fixMissingThumbnailsCommand(ctx, opts)).toBe(1);
expect(JSON.parse(stdout.text)).toMatchObject([
{ fileID: 100, reason: "file not found in any collection" },
{ fileID: 200, reason: "file not found in any collection" },
]);
expect(requests).not.toHaveLength(0);
});
// One bad ID fails the whole command, so the good one before it is not
// fixed either.
it.each(NOT_IDS)(
"fix-missing-thumbnails refuses --file 100 %j before any request",
async (arg) => {
const ctx = emptyAccount();
const opts = { file: ["100", arg] };
expect(await fixMissingThumbnailsCommand(ctx, opts)).toBe(1);
expect(stderr.text).toBe(`Invalid file ID: ${arg}\n`);
expect(stdout.text).toBe("");
expect(requests).toHaveLength(0);
},
);
});
+14 -173
View File
@@ -37,15 +37,8 @@ import {
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import sodium from "libsodium-wrappers-sumo"; import sodium from "libsodium-wrappers-sumo";
import { SRP, SrpServer } from "fast-srp-hap";
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest"; import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { import { init, toBase64, encryptBlob } from "../../src/crypto/index.js";
init,
toBase64,
deriveKEK,
deriveLoginSubkey,
encryptBlob,
} from "../../src/crypto/index.js";
import * as jpegJs from "jpeg-js"; import * as jpegJs from "jpeg-js";
import { Client } from "../../src/client.js"; import { Client } from "../../src/client.js";
import { Library } from "../../src/library/index.js"; import { Library } from "../../src/library/index.js";
@@ -54,7 +47,13 @@ import {
type MetadataBackupOptions, type MetadataBackupOptions,
} from "../../src/metadata-backup.js"; } from "../../src/metadata-backup.js";
import { backupMetadataCommand } from "../../src/cli-commands.js"; import { backupMetadataCommand } from "../../src/cli-commands.js";
import type { KeyAttributes } from "../../src/auth/types.js"; import {
buildAccount,
buildMetaFetch,
encryptSecretbox,
encryptStreamBlob,
type MetaMockState,
} from "../ente-server.js";
// One file per ML data request, so the two files of the mock account are // One file per ML data request, so the two files of the mock account are
// fetched in two requests and one of them can fail on its own. // fetched in two requests and one of them can fail on its own.
@@ -65,80 +64,13 @@ vi.mock("../../src/mldata-fetch.js", async (importOriginal) => ({
const TEST_EMAIL = "metabackup@example.com"; const TEST_EMAIL = "metabackup@example.com";
const TEST_PASSWORD = "metapass"; const TEST_PASSWORD = "metapass";
const TEST_OPS = 2;
const TEST_MEM = 64 * 1024 * 1024;
interface MetaMockState {
verifier: Buffer;
srpAttributes: Record<string, unknown>;
keyAttributes: KeyAttributes;
encryptedToken: string;
collections: Record<string, unknown>[];
filesByCollection: Record<number, Record<string, unknown>[]>;
// For ML data and EXIF tests
encryptedMLData: Record<
number,
{ encryptedData: string; decryptionHeader: string }
>;
fileCiphertexts: Record<number, Uint8Array>;
}
let mock: MetaMockState; let mock: MetaMockState;
let testDir: string; let testDir: string;
const encryptSecretbox = (plaintext: Uint8Array, key: Uint8Array) => {
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key);
return { ciphertext, nonce };
};
const encryptStreamBlob = (plaintext: Uint8Array, key: Uint8Array) => {
const push = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
const ciphertext = sodium.crypto_secretstream_xchacha20poly1305_push(
push.state,
plaintext,
null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
);
return { ciphertext, header: push.header };
};
const buildMetaMock = async (): Promise<MetaMockState> => { const buildMetaMock = async (): Promise<MetaMockState> => {
const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES); const account = await buildAccount(TEST_PASSWORD);
const kek = await deriveKEK(TEST_PASSWORD, kekSalt, TEST_OPS, TEST_MEM); const masterKey = account.masterKey;
const loginSubKeyBytes = deriveLoginSubkey(kek);
const srpUserID = "meta-srp";
const srpSalt = sodium.randombytes_buf(16);
const verifier = SRP.computeVerifier(
SRP.params["4096"],
Buffer.from(srpSalt),
Buffer.from(srpUserID),
Buffer.from(loginSubKeyBytes),
);
const masterKey = sodium.randombytes_buf(32);
const { ciphertext: encMK, nonce: mkNonce } = encryptSecretbox(
masterKey,
kek,
);
const kp = sodium.crypto_box_keypair();
const { ciphertext: encSK, nonce: skNonce } = encryptSecretbox(
kp.privateKey,
masterKey,
);
const tokenBytes = sodium.randombytes_buf(32);
const encToken = sodium.crypto_box_seal(tokenBytes, kp.publicKey);
const keyAttributes: KeyAttributes = {
kekSalt: toBase64(kekSalt),
encryptedKey: toBase64(encMK),
keyDecryptionNonce: toBase64(mkNonce),
publicKey: toBase64(kp.publicKey),
encryptedSecretKey: toBase64(encSK),
secretKeyDecryptionNonce: toBase64(skNonce),
memLimit: TEST_MEM,
opsLimit: TEST_OPS,
};
// Collection 1: "Vacation" with collection-level pubMagicMetadata // Collection 1: "Vacation" with collection-level pubMagicMetadata
const ck1 = sodium.crypto_secretbox_keygen(); const ck1 = sodium.crypto_secretbox_keygen();
@@ -332,17 +264,10 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
rawFile1.file.decryptionHeader = toBase64(filePush1.header); rawFile1.file.decryptionHeader = toBase64(filePush1.header);
return { return {
verifier, verifier: account.verifier,
srpAttributes: { srpAttributes: account.srpAttributes,
srpUserID, keyAttributes: account.keyAttributes,
srpSalt: toBase64(srpSalt), encryptedToken: account.encryptedToken,
memLimit: TEST_MEM,
opsLimit: TEST_OPS,
kekSalt: toBase64(kekSalt),
isEmailMFAEnabled: false,
},
keyAttributes,
encryptedToken: toBase64(encToken),
collections: [rawColl1, rawColl2], collections: [rawColl1, rawColl2],
filesByCollection: { 10: [rawFile1], 20: [rawFile2] }, filesByCollection: { 10: [rawFile1], 20: [rawFile2] },
encryptedMLData: { encryptedMLData: {
@@ -355,90 +280,6 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
}; };
}; };
// `failMLDataFor`: answer 500 to every ML data request that asks for this file.
const buildMetaFetch = (m: MetaMockState, failMLDataFor?: number) => {
let srpServer: SrpServer;
return (async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const path = new URL(url).pathname;
const json = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
if (path === "/users/srp/attributes")
return json({ attributes: m.srpAttributes });
if (path === "/users/srp/create-session") {
const body = JSON.parse(init?.body as string);
const serverKey = await SRP.genKey();
srpServer = new SrpServer(
SRP.params["4096"],
m.verifier,
serverKey,
);
const B = srpServer.computeB();
srpServer.setA(Buffer.from(body.srpA, "base64"));
return json({ sessionID: "s1", srpB: B.toString("base64") });
}
if (path === "/users/srp/verify-session") {
const body = JSON.parse(init?.body as string);
srpServer.checkM1(Buffer.from(body.srpM1, "base64"));
return json({
srpM2: srpServer.computeM2().toString("base64"),
id: 42,
keyAttributes: m.keyAttributes,
encryptedToken: m.encryptedToken,
});
}
if (path === "/collections/v2")
return json({ collections: m.collections });
if (path === "/collections/v2/diff") {
const collID = Number(
new URL(url).searchParams.get("collectionID"),
);
return json({
diff: m.filesByCollection[collID] ?? [],
hasMore: false,
});
}
if (path === "/files/data/fetch") {
const body = JSON.parse(init?.body as string);
if ((body.fileIDs as number[]).includes(failMLDataFor!))
return new Response("server error", { status: 500 });
const data = (body.fileIDs as number[])
.filter((id: number) => m.encryptedMLData[id])
.map((id: number) => ({
fileID: id,
...m.encryptedMLData[id],
updatedAt: 1700000000000000,
}));
return json({ data });
}
if (
url.includes("files.ente.io") ||
path.startsWith("/files/download/")
) {
const parsed = new URL(url);
const fileID = Number(
parsed.searchParams.get("fileID") ?? path.split("/").pop(),
);
const ct = m.fileCiphertexts[fileID];
if (ct) return new Response(ct, { status: 200 });
return new Response("not found", { status: 404 });
}
return new Response("not found", { status: 404 });
}) as typeof globalThis.fetch;
};
beforeAll(async () => { beforeAll(async () => {
await init(); await init();
await sodium.ready; await sodium.ready;
+23
View File
@@ -5,6 +5,7 @@
import { PassThrough } from "node:stream"; import { PassThrough } from "node:stream";
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { ApiError } from "../../src/api/client.js";
import { run } from "../../src/cli-run.js"; import { run } from "../../src/cli-run.js";
// A stream whose written text is kept in `text`; writes finish at once, so // A stream whose written text is kept in `text`; writes finish at once, so
@@ -55,4 +56,26 @@ describe("run", () => {
stderr: "quak: offline\n", stderr: "quak: offline\n",
}); });
}); });
it("on a 401 from the server says to run quak login, on one line, and exits 3", async () => {
const result = await runToExit(
Promise.reject(new ApiError("unauthorized", 401)),
);
expect(result).toEqual({
code: 3,
stdout: "",
stderr: `quak: the saved session is no longer valid; run "quak login"\n`,
});
});
it("prints another HTTP error as it is and exits 1", async () => {
const result = await runToExit(
Promise.reject(new ApiError("forbidden", 403)),
);
expect(result).toEqual({
code: 1,
stdout: "",
stderr: "quak: forbidden\n",
});
});
}); });
+126 -10
View File
@@ -10,7 +10,9 @@
* The return shape keeps live records and tombstones apart — `collections` / * The return shape keeps live records and tombstones apart — `collections` /
* `files` are decrypted live records, `deleted` is a plain list of the ids the * `files` are decrypted live records, `deleted` is a plain list of the ids the
* server tombstoned. A tombstone carries no decryptable key or metadata, so it * server tombstoned. A tombstone carries no decryptable key or metadata, so it
* is a bare id rather than a hollowed-out `Collection` / `EnteFile`. * is a bare id rather than a hollowed-out `Collection` / `EnteFile`. A live row
* that does not decrypt, or whose metadata is not JSON, is listed under
* `undecryptable` with the reason, so one bad row does not fail the call.
* *
* All tests inject a fake `fetch` and drive a real `Client` (built with * All tests inject a fake `fetch` and drive a real `Client` (built with
* `Client.fromJSON`) so the decryption path runs for real. Live rows are built * `Client.fromJSON`) so the decryption path runs for real. Live rows are built
@@ -91,27 +93,38 @@ const ownedCollectionRow = (
}; };
}; };
/** A live file row inside a collection, keyed under that collection's key. */ /**
* A live file row inside a collection, keyed under that collection's key. Its
* metadata decrypts to `metadata` when given, otherwise to the JSON a client
* uploads for `title`.
*/
const fileRow = ( const fileRow = (
collectionKey: Uint8Array, collectionKey: Uint8Array,
opts: { id: number; title: string; updationTime: number }, opts: {
id: number;
title: string;
updationTime: number;
metadata?: string;
},
): Record<string, unknown> => { ): Record<string, unknown> => {
const fileKey = sodium.crypto_secretbox_keygen(); const fileKey = sodium.crypto_secretbox_keygen();
const { ciphertext: encFileKey, nonce: fileKeyNonce } = secretboxEncrypt( const { ciphertext: encFileKey, nonce: fileKeyNonce } = secretboxEncrypt(
fileKey, fileKey,
collectionKey, collectionKey,
); );
const metadata = { const metadata =
title: opts.title, opts.metadata ??
fileType: 0, JSON.stringify({
creationTime: opts.updationTime, title: opts.title,
modificationTime: opts.updationTime, fileType: 0,
}; creationTime: opts.updationTime,
modificationTime: opts.updationTime,
});
const push = const push =
sodium.crypto_secretstream_xchacha20poly1305_init_push(fileKey); sodium.crypto_secretstream_xchacha20poly1305_init_push(fileKey);
const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push( const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push(
push.state, push.state,
new TextEncoder().encode(JSON.stringify(metadata)), new TextEncoder().encode(metadata),
null, null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
); );
@@ -131,6 +144,21 @@ const fileRow = (
}; };
}; };
/**
* `row` with its sealed key replaced by random bytes of the same length, as a
* corrupt row arrives: no key opens it, so the row cannot be decrypted.
*/
const withCorruptKey = (
row: Record<string, unknown>,
): Record<string, unknown> => ({
...row,
encryptedKey: toBase64(
sodium.randombytes_buf(
sodium.crypto_secretbox_KEYBYTES + sodium.crypto_secretbox_MACBYTES,
),
),
});
/** A tombstone row. Never decrypted, so only these fields are ever read. */ /** A tombstone row. Never decrypted, so only these fields are ever read. */
const tombstoneRow = ( const tombstoneRow = (
id: number, id: number,
@@ -278,6 +306,58 @@ describe("Client.filesSince", () => {
// It gave up after the first page rather than looping. // It gave up after the first page rather than looping.
expect(sinceTimes).toEqual(["50"]); expect(sinceTimes).toEqual(["50"]);
}); });
it("leaves out each file it cannot decrypt or parse, with the reason, and decrypts the rest", async () => {
const keys = buildKeys();
const collectionKey = sodium.crypto_secretbox_keygen();
// File 1002's key is sealed under no key quak holds, and file 1003's
// metadata decrypts to text that is not JSON. Neither may cost the
// caller file 1001: each is left out of `files` and reported under
// `undecryptable` with the reason instead of failing the call.
const { fetch } = recordingFetch(
jsonResponse({
diff: [
fileRow(collectionKey, {
id: 1001,
title: "fine.jpg",
updationTime: 100,
}),
withCorruptKey(
fileRow(collectionKey, {
id: 1002,
title: "bad-key.jpg",
updationTime: 200,
}),
),
fileRow(collectionKey, {
id: 1003,
title: "bad-metadata.jpg",
updationTime: 300,
metadata: "not json",
}),
],
hasMore: false,
}),
);
const client = Client.fromJSON(snapshotFor(keys), { fetch });
const { files, undecryptable, cursor } = await client.filesSince({
collectionID: 1,
collectionKey,
sinceTime: 0,
});
expect(files.map((f) => f.id)).toEqual([1001]);
expect(undecryptable).toEqual([
{ id: 1002, error: expect.stringContaining("wrong secret key") },
{ id: 1003, error: expect.stringContaining("JSON") },
]);
// The cursor moves past the two left out, as past any other row, so
// the server does not send them again until its copy changes. The
// caller has to keep what it was told here.
expect(cursor).toBe(300);
});
}); });
describe("Client.collectionsSince", () => { describe("Client.collectionsSince", () => {
@@ -331,6 +411,42 @@ describe("Client.collectionsSince", () => {
expect(result.cursor).toBe(150); expect(result.cursor).toBe(150);
expect(sinceTimes).toEqual(["150"]); expect(sinceTimes).toEqual(["150"]);
}); });
it("leaves out an album it cannot decrypt, with the reason, and decrypts the rest", async () => {
const keys = buildKeys();
// Album 2's key is sealed under no key quak holds. Without its key
// neither its name nor its files can be read, so it is left out and
// reported under `undecryptable`; album 1 is unaffected.
const { fetch } = recordingFetch(
jsonResponse({
collections: [
ownedCollectionRow(keys.masterKey, {
id: 1,
name: "Vacation",
updationTime: 100,
}),
withCorruptKey(
ownedCollectionRow(keys.masterKey, {
id: 2,
name: "Broken",
updationTime: 200,
}),
),
],
}),
);
const client = Client.fromJSON(snapshotFor(keys), { fetch });
const { collections, undecryptable, cursor } =
await client.collectionsSince({ sinceTime: 0 });
expect(collections.map((c) => c.name)).toEqual(["Vacation"]);
expect(undecryptable).toEqual([
{ id: 2, error: expect.stringContaining("wrong secret key") },
]);
expect(cursor).toBe(200);
});
}); });
describe("Client list wrappers still hide deletions", () => { describe("Client list wrappers still hide deletions", () => {
+18
View File
@@ -12,6 +12,10 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { init, toBase64 } from "../../src/crypto/index.js"; import { init, toBase64 } from "../../src/crypto/index.js";
import { Client, type ClientSnapshot } from "../../src/client.js"; import { Client, type ClientSnapshot } from "../../src/client.js";
import { loadSession } from "../../src/cli-session.js"; import { loadSession } from "../../src/cli-session.js";
import {
DEFAULT_RETRY_OPTIONS,
UNATTENDED_RETRY_OPTIONS,
} from "../../src/retry.js";
const validSnapshot = (): ClientSnapshot => { const validSnapshot = (): ClientSnapshot => {
const kp = sodium.crypto_box_keypair(); const kp = sodium.crypto_box_keypair();
@@ -176,6 +180,20 @@ describe("loadSession", () => {
}); });
}); });
it("gives the restored client the retry options it is passed", () => {
const path = join(dir, "retry.json");
writeFileSync(path, JSON.stringify(validSnapshot()));
const retryOptions = (client: Client | null) =>
client!.getApiClient().getRetryOptions();
expect(
retryOptions(
loadSession(path, { retry: UNATTENDED_RETRY_OPTIONS }),
),
).toEqual(UNATTENDED_RETRY_OPTIONS);
expect(retryOptions(loadSession(path))).toEqual(DEFAULT_RETRY_OPTIONS);
});
it("says the file is corrupt when it is not JSON", () => { it("says the file is corrupt when it is not JSON", () => {
const path = join(dir, "truncated.json"); const path = join(dir, "truncated.json");
writeFileSync(path, '{"email": "user@exa'); writeFileSync(path, '{"email": "user@exa');
+197
View File
@@ -0,0 +1,197 @@
/**
* A fake of the Ente HTTP API, for tests that run quak's real `Client` against
* a server.
*
* `buildMetaFetch` is a `fetch` that answers the requests quak makes, from a
* `MetaMockState` the test builds: the SRP login, `/collections/v2`, each
* collection's `/collections/v2/diff`, the ML data of `/files/data/fetch`, and
* each file's content from the files CDN, a 404 for a file it holds none for.
* The test encrypts everything in that state with libsodium, as Ente's clients
* do, so quak decrypts it with its own code. Nothing hits the network.
*
* `buildAccount` builds what the server holds to log an account in, and the
* keys a test encrypts that account's collections with. Call `init()` from
* `src/crypto` and await `sodium.ready` before using either.
*/
import sodium from "libsodium-wrappers-sumo";
import { SRP, SrpServer } from "fast-srp-hap";
import { toBase64, deriveKEK, deriveLoginSubkey } from "../src/crypto/index.js";
import type { KeyAttributes } from "../src/auth/types.js";
const TEST_OPS = 2;
const TEST_MEM = 64 * 1024 * 1024;
export interface MetaMockState {
verifier: Buffer;
srpAttributes: Record<string, unknown>;
keyAttributes: KeyAttributes;
encryptedToken: string;
collections: Record<string, unknown>[];
filesByCollection: Record<number, Record<string, unknown>[]>;
// For ML data and EXIF tests
encryptedMLData: Record<
number,
{ encryptedData: string; decryptionHeader: string }
>;
fileCiphertexts: Record<number, Uint8Array>;
}
export const encryptSecretbox = (plaintext: Uint8Array, key: Uint8Array) => {
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key);
return { ciphertext, nonce };
};
export const encryptStreamBlob = (plaintext: Uint8Array, key: Uint8Array) => {
const push = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
const ciphertext = sodium.crypto_secretstream_xchacha20poly1305_push(
push.state,
plaintext,
null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
);
return { ciphertext, header: push.header };
};
// An account whose password is `password`. The first four fields are those of
// `MetaMockState`; with the master key the test encrypts the keys of the
// account's own collections, and to the public key it seals the keys of the
// collections other accounts share with it.
export const buildAccount = async (password: string) => {
const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
const kek = await deriveKEK(password, kekSalt, TEST_OPS, TEST_MEM);
const loginSubKeyBytes = deriveLoginSubkey(kek);
const srpUserID = "meta-srp";
const srpSalt = sodium.randombytes_buf(16);
const verifier = SRP.computeVerifier(
SRP.params["4096"],
Buffer.from(srpSalt),
Buffer.from(srpUserID),
Buffer.from(loginSubKeyBytes),
);
const masterKey = sodium.randombytes_buf(32);
const { ciphertext: encMK, nonce: mkNonce } = encryptSecretbox(
masterKey,
kek,
);
const kp = sodium.crypto_box_keypair();
const { ciphertext: encSK, nonce: skNonce } = encryptSecretbox(
kp.privateKey,
masterKey,
);
const tokenBytes = sodium.randombytes_buf(32);
const encToken = sodium.crypto_box_seal(tokenBytes, kp.publicKey);
const keyAttributes: KeyAttributes = {
kekSalt: toBase64(kekSalt),
encryptedKey: toBase64(encMK),
keyDecryptionNonce: toBase64(mkNonce),
publicKey: toBase64(kp.publicKey),
encryptedSecretKey: toBase64(encSK),
secretKeyDecryptionNonce: toBase64(skNonce),
memLimit: TEST_MEM,
opsLimit: TEST_OPS,
};
return {
verifier,
srpAttributes: {
srpUserID,
srpSalt: toBase64(srpSalt),
memLimit: TEST_MEM,
opsLimit: TEST_OPS,
kekSalt: toBase64(kekSalt),
isEmailMFAEnabled: false,
},
keyAttributes,
encryptedToken: toBase64(encToken),
masterKey,
publicKey: kp.publicKey,
};
};
// `failMLDataFor`: answer 500 to every ML data request that asks for this file.
export const buildMetaFetch = (m: MetaMockState, failMLDataFor?: number) => {
let srpServer: SrpServer;
return (async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const path = new URL(url).pathname;
const json = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
if (path === "/users/srp/attributes")
return json({ attributes: m.srpAttributes });
if (path === "/users/srp/create-session") {
const body = JSON.parse(init?.body as string);
const serverKey = await SRP.genKey();
srpServer = new SrpServer(
SRP.params["4096"],
m.verifier,
serverKey,
);
const B = srpServer.computeB();
srpServer.setA(Buffer.from(body.srpA, "base64"));
return json({ sessionID: "s1", srpB: B.toString("base64") });
}
if (path === "/users/srp/verify-session") {
const body = JSON.parse(init?.body as string);
srpServer.checkM1(Buffer.from(body.srpM1, "base64"));
return json({
srpM2: srpServer.computeM2().toString("base64"),
id: 42,
keyAttributes: m.keyAttributes,
encryptedToken: m.encryptedToken,
});
}
if (path === "/collections/v2")
return json({ collections: m.collections });
if (path === "/collections/v2/diff") {
const collID = Number(
new URL(url).searchParams.get("collectionID"),
);
return json({
diff: m.filesByCollection[collID] ?? [],
hasMore: false,
});
}
if (path === "/files/data/fetch") {
const body = JSON.parse(init?.body as string);
if ((body.fileIDs as number[]).includes(failMLDataFor!))
return new Response("server error", { status: 500 });
const data = (body.fileIDs as number[])
.filter((id: number) => m.encryptedMLData[id])
.map((id: number) => ({
fileID: id,
...m.encryptedMLData[id],
updatedAt: 1700000000000000,
}));
return json({ data });
}
if (
url.includes("files.ente.io") ||
path.startsWith("/files/download/")
) {
const parsed = new URL(url);
const fileID = Number(
parsed.searchParams.get("fileID") ?? path.split("/").pop(),
);
const ct = m.fileCiphertexts[fileID];
if (ct) return new Response(ct, { status: 200 });
return new Response("not found", { status: 404 });
}
return new Response("not found", { status: 404 });
}) as typeof globalThis.fetch;
};
+2 -2
View File
@@ -1,7 +1,7 @@
/** /**
* `exif.heic`, beside this file: a real 64x64 HEIC whose EXIF holds the same * `exif.heic`, beside this file: a real 64x64 HEIC whose EXIF holds the same
* values as the hand-built JPEG in `library/content-library.test.ts`, for the * values as the hand-built JPEG in `exif-jpeg.ts`, for the tests of `exif()`,
* tests of `exif()` and `backup-metadata --exif`. * `backup-metadata --exif` and the image metadata `quak backup` records.
* *
* It was made once, in a throwaway node:22-alpine container (Alpine 3.23.3), * It was made once, in a throwaway node:22-alpine container (Alpine 3.23.3),
* with libheif 1.23.0 and exiftool 13.55: * with libheif 1.23.0 and exiftool 13.55:
+89
View File
@@ -0,0 +1,89 @@
/**
* Hand-built JPEGs for the tests of `exif()` and of the image metadata
* `quak backup` records: one whose EXIF holds the same values as `exif.heic`
* (see `exif-heic.ts`), and one whose EXIF cannot be parsed.
*/
// Big-endian bytes for the hand-built JPEG below.
const u16 = (n: number): number[] => [n >> 8, n & 0xff];
const u32 = (n: number): number[] => [...u16(n >>> 16), ...u16(n & 0xffff)];
const ascii = (s: string): number[] => [...new TextEncoder().encode(s), 0];
const rational = (num: number, den: number): number[] => [
...u32(num),
...u32(den),
];
// One IFD entry: tag, type (1 BYTE, 2 ASCII, 3 SHORT, 4 LONG, 5 RATIONAL),
// count, then the value when it fits in 4 bytes, else its offset.
const entry = (
tag: number,
type: number,
count: number,
value: number[],
): number[] => [...u16(tag), ...u16(type), ...u32(count), ...value];
// The TIFF block of a JPEG's EXIF segment, holding every field `Photo`'s typed
// methods return: the camera in the first IFD, the exposure in the Exif IFD,
// and a GPS position of 40°26'46" N, 79°58'56" W, 12.5 m below sea level.
// Offsets count from the start of this block.
const TIFF = [
...[0x4d, 0x4d, 0x00, 0x2a], // big-endian TIFF
...u32(8), // the first IFD's offset
// The first IFD, at 8: five entries, then no next IFD.
...u16(5),
...entry(0x010f, 2, 6, u32(74)), // Make
...entry(0x0110, 2, 7, u32(80)), // Model
...entry(0x0112, 3, 1, [...u16(6), 0, 0]), // Orientation
...entry(0x8769, 4, 1, u32(88)), // the Exif IFD's offset
...entry(0x8825, 4, 1, u32(246)), // the GPS IFD's offset
...u32(0),
...ascii("Canon"), // at 74
...ascii("EOS R5"), // at 80
0, // a pad byte
// The Exif IFD, at 88: seven entries, then no next IFD.
...u16(7),
...entry(0x829a, 5, 1, u32(178)), // ExposureTime
...entry(0x829d, 5, 1, u32(186)), // FNumber
...entry(0x8827, 3, 1, [...u16(400), 0, 0]), // ISOSpeedRatings
...entry(0x9003, 2, 20, u32(194)), // DateTimeOriginal
...entry(0x9011, 2, 7, u32(214)), // OffsetTimeOriginal
...entry(0x920a, 5, 1, u32(222)), // FocalLength
...entry(0xa434, 2, 16, u32(230)), // LensModel
...u32(0),
...rational(1, 250), // at 178
...rational(28, 10), // at 186
...ascii("2021:07:15 14:30:00"), // at 194
...ascii("+02:00"), // at 214
0, // a pad byte
...rational(50, 1), // at 222
...ascii("RF50mm F1.8 STM"), // at 230
// The GPS IFD, at 246: six entries, then no next IFD.
...u16(6),
...entry(0x0001, 2, 2, [...ascii("N"), 0, 0]), // GPSLatitudeRef
...entry(0x0002, 5, 3, u32(324)), // GPSLatitude
...entry(0x0003, 2, 2, [...ascii("W"), 0, 0]), // GPSLongitudeRef
...entry(0x0004, 5, 3, u32(348)), // GPSLongitude
...entry(0x0005, 1, 1, [1, 0, 0, 0]), // GPSAltitudeRef: below sea level
...entry(0x0006, 5, 1, u32(372)), // GPSAltitude
...u32(0),
...[...rational(40, 1), ...rational(26, 1), ...rational(46, 1)], // at 324
...[...rational(79, 1), ...rational(58, 1), ...rational(56, 1)], // at 348
...rational(25, 2), // at 372
];
export const JPEG_WITH_EXIF = new Uint8Array([
...[0xff, 0xd8], // start of image
...[0xff, 0xe1, ...u16(2 + 6 + TIFF.length)], // APP1 and its length
...[...ascii("Exif"), 0], // "Exif\0\0"
...TIFF,
...[0xff, 0xda, 0x00, 0x02], // start of scan
]);
// A JPEG whose EXIF segment is laid out correctly but holds "XX" where the TIFF
// byte order belongs, so exifreader cannot parse it.
export const JPEG_WITH_BAD_EXIF = new Uint8Array([
...[0xff, 0xd8], // start of image
...[0xff, 0xe1, ...u16(2 + 6 + 2)], // APP1 and its length
...[...ascii("Exif"), 0], // "Exif\0\0"
...[0x58, 0x58], // "XX"
...[0xff, 0xda, 0x00, 0x02], // start of scan
]);
+1 -84
View File
@@ -29,6 +29,7 @@ import type { CollectionsPage, FilesPage } from "../../src/client.js";
import type { Collection, EnteFile } from "../../src/model/types.js"; import type { Collection, EnteFile } from "../../src/model/types.js";
import { readPhotoExif, type PhotoExif } from "../../src/exif.js"; import { readPhotoExif, type PhotoExif } from "../../src/exif.js";
import { HEIC_WITH_EXIF } from "../exif-heic.js"; import { HEIC_WITH_EXIF } from "../exif-heic.js";
import { JPEG_WITH_BAD_EXIF, JPEG_WITH_EXIF } from "../exif-jpeg.js";
import { import {
asLivePhoto, asLivePhoto,
cdnSource, cdnSource,
@@ -256,90 +257,6 @@ describe("Library content wiring", () => {
}); });
}); });
// Big-endian bytes for the hand-built JPEG below.
const u16 = (n: number): number[] => [n >> 8, n & 0xff];
const u32 = (n: number): number[] => [...u16(n >>> 16), ...u16(n & 0xffff)];
const ascii = (s: string): number[] => [...new TextEncoder().encode(s), 0];
const rational = (num: number, den: number): number[] => [
...u32(num),
...u32(den),
];
// One IFD entry: tag, type (1 BYTE, 2 ASCII, 3 SHORT, 4 LONG, 5 RATIONAL),
// count, then the value when it fits in 4 bytes, else its offset.
const entry = (
tag: number,
type: number,
count: number,
value: number[],
): number[] => [...u16(tag), ...u16(type), ...u32(count), ...value];
// The TIFF block of a JPEG's EXIF segment, holding every field `Photo`'s typed
// methods return: the camera in the first IFD, the exposure in the Exif IFD,
// and a GPS position of 40°26'46" N, 79°58'56" W, 12.5 m below sea level.
// Offsets count from the start of this block.
const TIFF = [
...[0x4d, 0x4d, 0x00, 0x2a], // big-endian TIFF
...u32(8), // the first IFD's offset
// The first IFD, at 8: five entries, then no next IFD.
...u16(5),
...entry(0x010f, 2, 6, u32(74)), // Make
...entry(0x0110, 2, 7, u32(80)), // Model
...entry(0x0112, 3, 1, [...u16(6), 0, 0]), // Orientation
...entry(0x8769, 4, 1, u32(88)), // the Exif IFD's offset
...entry(0x8825, 4, 1, u32(246)), // the GPS IFD's offset
...u32(0),
...ascii("Canon"), // at 74
...ascii("EOS R5"), // at 80
0, // a pad byte
// The Exif IFD, at 88: seven entries, then no next IFD.
...u16(7),
...entry(0x829a, 5, 1, u32(178)), // ExposureTime
...entry(0x829d, 5, 1, u32(186)), // FNumber
...entry(0x8827, 3, 1, [...u16(400), 0, 0]), // ISOSpeedRatings
...entry(0x9003, 2, 20, u32(194)), // DateTimeOriginal
...entry(0x9011, 2, 7, u32(214)), // OffsetTimeOriginal
...entry(0x920a, 5, 1, u32(222)), // FocalLength
...entry(0xa434, 2, 16, u32(230)), // LensModel
...u32(0),
...rational(1, 250), // at 178
...rational(28, 10), // at 186
...ascii("2021:07:15 14:30:00"), // at 194
...ascii("+02:00"), // at 214
0, // a pad byte
...rational(50, 1), // at 222
...ascii("RF50mm F1.8 STM"), // at 230
// The GPS IFD, at 246: six entries, then no next IFD.
...u16(6),
...entry(0x0001, 2, 2, [...ascii("N"), 0, 0]), // GPSLatitudeRef
...entry(0x0002, 5, 3, u32(324)), // GPSLatitude
...entry(0x0003, 2, 2, [...ascii("W"), 0, 0]), // GPSLongitudeRef
...entry(0x0004, 5, 3, u32(348)), // GPSLongitude
...entry(0x0005, 1, 1, [1, 0, 0, 0]), // GPSAltitudeRef: below sea level
...entry(0x0006, 5, 1, u32(372)), // GPSAltitude
...u32(0),
...[...rational(40, 1), ...rational(26, 1), ...rational(46, 1)], // at 324
...[...rational(79, 1), ...rational(58, 1), ...rational(56, 1)], // at 348
...rational(25, 2), // at 372
];
const JPEG_WITH_EXIF = new Uint8Array([
...[0xff, 0xd8], // start of image
...[0xff, 0xe1, ...u16(2 + 6 + TIFF.length)], // APP1 and its length
...[...ascii("Exif"), 0], // "Exif\0\0"
...TIFF,
...[0xff, 0xda, 0x00, 0x02], // start of scan
]);
// A JPEG whose EXIF segment is laid out correctly but holds "XX" where the TIFF
// byte order belongs, so exifreader cannot parse it.
const JPEG_WITH_BAD_EXIF = new Uint8Array([
...[0xff, 0xd8], // start of image
...[0xff, 0xe1, ...u16(2 + 6 + 2)], // APP1 and its length
...[...ascii("Exif"), 0], // "Exif\0\0"
...[0x58, 0x58], // "XX"
...[0xff, 0xda, 0x00, 0x02], // start of scan
]);
describe("Photo save path, local copy, content and EXIF", () => { describe("Photo save path, local copy, content and EXIF", () => {
// The same account, with `files` in its album instead. // The same account, with `files` in its album instead.
class FilesClient extends MockClient { class FilesClient extends MockClient {
+2 -1
View File
@@ -30,7 +30,8 @@ export const livePhotoZip = (
}, },
): Uint8Array => zipSync(entries); ): Uint8Array => zipSync(entries);
const blake2b = (bytes: Uint8Array): string => // The content hash Ente's clients record for an original's bytes.
export const blake2b = (bytes: Uint8Array): string =>
createHash("blake2b512").update(bytes).digest("base64"); createHash("blake2b512").update(bytes).digest("base64");
// The hash Ente's clients record for a live photo: the unkeyed BLAKE2b-512 of // The hash Ente's clients record for a live photo: the unkeyed BLAKE2b-512 of
+13 -12
View File
@@ -29,18 +29,19 @@ const patterns = (name: string): string[] =>
const dockerignore = patterns(".dockerignore"); const dockerignore = patterns(".dockerignore");
describe(".dockerignore", () => { describe(".dockerignore", () => {
// Everything here is either generated, enormous, or secret. `.claude/` is // Everything here is either generated, enormous, or secret. `.claude` is
// the correctness one: see the header comment and issue #25. // the correctness one: see the header comment and issue #25. The leading
// `/` anchors an entry at the root of the context.
it.each([ it.each([
".claude/", ".claude",
".quak/", "/.quak",
"bin/quak", "/bin/quak",
"node_modules", "**/node_modules",
"coverage", "/coverage",
"dist", "/dist",
".vitest-cache/", "/.vitest-cache",
".nyc_output/", "/.nyc_output",
"*.tsbuildinfo", "/*.tsbuildinfo",
])("keeps %s out of the build context", (pattern) => { ])("keeps %s out of the build context", (pattern) => {
expect(dockerignore).toContain(pattern); expect(dockerignore).toContain(pattern);
}); });
@@ -57,7 +58,7 @@ describe(".dockerignore", () => {
// The build stage is the final image, so a .git/config sent in would // The build stage is the final image, so a .git/config sent in would
// ship the clone's remote URL and any credential in it. // ship the clone's remote URL and any credential in it.
it("sends .git without its config", () => { it("sends .git without its config", () => {
expect(dockerignore).toContain(".git/config"); expect(dockerignore).toContain("**/.git/config");
}); });
// BuildKit lets a `Dockerfile.dockerignore` shadow the root one; such a // BuildKit lets a `Dockerfile.dockerignore` shadow the root one; such a
+31
View File
@@ -43,6 +43,7 @@ import {
isRetryable, isRetryable,
isSafeToReplay, isSafeToReplay,
resolveRetryOptions, resolveRetryOptions,
UNATTENDED_RETRY_OPTIONS,
withRetry, withRetry,
} from "../../src/retry.js"; } from "../../src/retry.js";
import { ApiError, TruncatedStreamError } from "../../src/errors.js"; import { ApiError, TruncatedStreamError } from "../../src/errors.js";
@@ -639,3 +640,33 @@ describe("retry defaults", () => {
} }
}); });
}); });
describe("unattended retry options", () => {
it("allow ten attempts, a 1 s base delay and a 60 s cap", () => {
// The numbers the README documents for `quak backup`.
expect(UNATTENDED_RETRY_OPTIONS.attempts).toBe(10);
expect(UNATTENDED_RETRY_OPTIONS.baseDelayMs).toBe(1_000);
expect(UNATTENDED_RETRY_OPTIONS.maxDelayMs).toBe(60_000);
});
it("give up on a request that keeps failing after at most 243 s of waiting", async () => {
// `random: () => 1` makes every wait its ceiling, the worst case.
const { sleep, delays } = recordingSleep();
let calls = 0;
await expect(
withRetry(
() => {
calls++;
return Promise.reject(new ApiError("HTTP 503", 503));
},
{ ...UNATTENDED_RETRY_OPTIONS, sleep, random: () => 1 },
),
).rejects.toThrow("HTTP 503");
expect(calls).toBe(10);
expect(delays).toEqual([
1_000, 2_000, 4_000, 8_000, 16_000, 32_000, 60_000, 60_000, 60_000,
]);
expect(delays.reduce((sum, ms) => sum + ms, 0)).toBe(243_000);
});
});
+36
View File
@@ -535,6 +535,18 @@
dependencies: dependencies:
undici-types "~6.21.0" undici-types "~6.21.0"
"@types/proper-lockfile@4.1.4":
version "4.1.4"
resolved "https://registry.yarnpkg.com/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz#cd9fab92bdb04730c1ada542c356f03620f84008"
integrity sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==
dependencies:
"@types/retry" "*"
"@types/retry@*":
version "0.12.5"
resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.5.tgz#f090ff4bd8d2e5b940ff270ab39fd5ca1834a07e"
integrity sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==
"@typescript-eslint/eslint-plugin@8.46.2": "@typescript-eslint/eslint-plugin@8.46.2":
version "8.46.2" version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz#dc4ab93ee3d7e6c8e38820a0d6c7c93c7183e2dc" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz#dc4ab93ee3d7e6c8e38820a0d6c7c93c7183e2dc"
@@ -1140,6 +1152,11 @@ globals@^14.0.0:
resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e"
integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
graceful-fs@^4.2.4:
version "4.2.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
graphemer@^1.4.0: graphemer@^1.4.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6"
@@ -1414,6 +1431,15 @@ prettier@3.8.1:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
proper-lockfile@4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f"
integrity sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==
dependencies:
graceful-fs "^4.2.4"
retry "^0.12.0"
signal-exit "^3.0.2"
punycode@^2.1.0: punycode@^2.1.0:
version "2.3.1" version "2.3.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
@@ -1429,6 +1455,11 @@ resolve-from@^4.0.0:
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
retry@^0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==
reusify@^1.0.4: reusify@^1.0.4:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f"
@@ -1502,6 +1533,11 @@ siginfo@^2.0.0:
resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30"
integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==
signal-exit@^3.0.2:
version "3.0.7"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
signal-exit@^4.1.0: signal-exit@^4.1.0:
version "4.1.0" version "4.1.0"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"