Compare commits

1 Commits
Author SHA1 Message Date
clawbot c5a588177c Thumbnail test on main: re-encode a small image so it cannot time out (closes #153)
check / check (push) Successful in 1m19s
The `main` half of the fix for the timed-out thumbnail test, carrying only the test change from `next` (d788c54). The test "re-encodes smaller until the thumbnail fits the recorded size" built a noisy 400x300 JPEG, which came close to vitest's 5 s limit and made `script/cibuild` fail on a busy host. It now uses a noisy 64x48 JPEG, keeps every assertion, and runs in about 1 s.

Model: opus-5-5
2026-10-02 04:22:27 +02:00
63 changed files with 1363 additions and 8229 deletions
+39 -75
View File
@@ -1,86 +1,50 @@
# .dockerignore does NOT use .gitignore semantics. Docker matches with
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
# `/` and an unprefixed pattern is anchored at the context root. Every
# depth-independent pattern therefore needs `**/`, or `config/.env` and
# `certs/server.key` still ship while this file reads as solved. Only
# genuinely root-anchored entries go unprefixed. Never transplant these
# into .gitignore, where `**/` is wrong.
#
# Matching is case-sensitive, so secrets use character ranges rather
# than an ALL-CAPS twin, which would still miss `Server.Key`.
#
# Extend with this repo's own host-built artifacts, written anchored:
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
# deletes the package directory from the context.
# Mirrors .gitignore, with one deliberate exception: .gitignore itself stays
# in the build context, because prettier 3 reads it as a default ignore file
# and dropping it would change what the lint phase's prettier check sees.
# .git is sent without its config. Without a VERSION build argument the
# stage that compiles runs `git describe --tags --always` on .git, which
# does not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there.
# Each submodule keeps a config with the same exposure in its git directory
# under .git/modules/, nested again for a submodule's own submodules, or in
# its own .git directory when it keeps one.
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
# `deploy/config`, `config/lib`) loses its whole git directory, because
# `**/.git/modules/**/config` also matches that segment's directory
# under .git/modules/. Go's version stamping then fails the build;
# nothing leaks. Name such a submodule without that segment:
# `git submodule add --name`.
**/.git/config
**/.git/modules/**/config
# VCS
.git
# Agent scratch: one full checkout of the repo per in-flight agent.
# Anchored because it occurs once where agents run at the repo root.
# KNOWN GAP: a repo running agents in subdirectories still ships
# `services/api/.claude/` and must add its own anchored entry.
.claude
# OS
.DS_Store
Thumbs.db
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Re-include a committed template with a negation if the
# build needs one: `!docs/example.env`.
**/*.[eE][nN][vV]
**/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC]
# Editors
*.swp
*.swo
*~
*.bak
.idea/
.vscode/
*.sublime-*
# Private keys and the bundles carrying them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
**/*.[pP][eE][mM]
**/*.[kK][eE][yY]
**/*.[pP]12
**/*.[pP][fF][xX]
**/[iI][dD]_[rR][sS][aA]
**/[iI][dD]_[dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
**/[iI][dD]_[eE][dD]25519
**/[iI][dD]_[eE][dD]25519_[sS][kK]
# Node
node_modules
# Dependencies: restored inside the image, never copied in.
**/node_modules
# TypeScript / build artifacts
dist
build
*.tsbuildinfo
coverage
.nyc_output/
# OS metadata.
**/.DS_Store
**/Thumbs.db
# Vitest
.vitest-cache/
# 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
# Environment / secrets
.env
.env.*
*.pem
*.key
# 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
.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/
+10 -32
View File
@@ -11,41 +11,9 @@ Thumbs.db
.vscode/
*.sublime-*
# Agent scratch (worktrees of this repo, created and destroyed by
# in-flight tooling). Unanchored: .gitignore patterns already match at
# every depth, so no prefix is wanted here. This is not a .dockerignore
# entry and must not be given a `**/` prefix on the way into one.
.claude/
# Node
node_modules/
# Secrets. Unanchored like every entry above, so each matches at every
# depth. Matching is case-sensitive on Linux, so names use character
# ranges rather than a lowercase form that misses `Server.Key`.
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Only the templates `example.env` and `sample.env` are
# re-included below. A repository that commits any other template adds
# its own negation after these lines, for example `!.env.example`.
*.[eE][nN][vV]
.[eE][nN][vV].*
.[eE][nN][vV][rR][cC]
!example.env
!sample.env
# Private keys and the bundles carrying them.
*.[pP][eE][mM]
*.[kK][eE][yY]
*.[pP]12
*.[pP][fF][xX]
[iI][dD]_[rR][sS][aA]
[iI][dD]_[dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
[iI][dD]_[eE][dD]25519
[iI][dD]_[eE][dD]25519_[sS][kK]
# TypeScript / build artifacts
dist/
build/
@@ -56,8 +24,18 @@ coverage/
# Vitest
.vitest-cache/
# Environment / secrets
.env
.env.*
*.pem
*.key
# Compiled binary (built by make build-bin)
bin/quak
# quak runtime data (in case anyone runs the CLI from inside the repo)
.quak/
# Local per-developer tool settings and scratch state, including the
# worktrees agents check out under this directory
.claude/
+3
View File
@@ -1,2 +1,5 @@
node_modules/
yarn.lock
dist/
build/
coverage/
+3 -8
View File
@@ -58,17 +58,12 @@ COPY --from=test /app/package.json /dev/null
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /app
COPY . .
# Version stamped into the build: the VERSION build arg when one is given,
# otherwise what script/version derives from the .git the build context
# carries (script/bootstrap installed git), so any `docker build .` of a
# clone stamps its commit. The label can only carry the build arg, and is
# empty without one.
ARG VERSION
# The version is computed on the host and passed in, because
# .dockerignore excludes .git.
ARG VERSION=dev
LABEL org.opencontainers.image.version="${VERSION}"
RUN make build
+2 -4
View File
@@ -26,10 +26,8 @@ check:
build:
@script/build
# Bundles the built dist/, so the binary reports the version script/build
# stamped.
build-bin: build
nix-shell -p bun --run "bun build dist/bin/quak.js --compile --outfile bin/quak"
build-bin:
nix-shell -p bun --run "bun build bin/quak.ts --compile --outfile bin/quak"
install: build-bin
mkdir -p ~/bin
+207 -575
View File
File diff suppressed because it is too large Load Diff
+44 -120
View File
@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-10-04
last_modified: 2026-09-08
---
This document covers repository structure, tooling, and workflow standards. Code
@@ -104,14 +104,10 @@ style conventions are in separate documents:
`lint` phase and a `test` phase, with the final stage depending on both so the
image cannot be built unless they pass. For non-server repos the final stage
brings up a development environment; for server repos it is the runtime image.
The gate phases and the build stage start from their pinned base images and
install what those images lack either inline, as the canonical Go `Dockerfile`
below does for `git`, or by running `script/bootstrap`, as the `prompts`
repo's own `Dockerfile` does for its yarn packages. The development
environment stage installs development prerequisites by running
`script/bootstrap` rather than duplicating its installs inline. A stage that
runs `script/bootstrap` COPYs `script/` and the dependency manifests
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
Dockerfiles install development prerequisites by running `script/bootstrap`
rather than duplicating installs inline; COPY `script/` and the dependency
manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before
running it.
- **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
@@ -160,14 +156,11 @@ style conventions are in separate documents:
not evidence that anything ran: a sub-second build reporting success is a
cache hit, not a result. Never invalidate by pruning — `docker builder prune`
and friends destroy a build cache shared with every other build on the host.
When a check is added or changed, prove it works by planting a defect it must
catch and watching the run fail on it, then revert the defect. A green run
alone shows neither that the check ran nor that it covers what it should.
- **The gate phases are separate stages, and the build stage depends on both.**
The lint phase is based on the `golangci/golangci-lint` image (pinned by
hash), so lint failures surface in seconds rather than after a full compile,
and the test phase is based on the Debian Go image. The canonical Go repo
and the test phase is based on the Go image. The canonical Go repo
`Dockerfile`:
```dockerfile
@@ -180,9 +173,8 @@ style conventions are in separate documents:
COPY . .
RUN golangci-lint run --config .golangci.yml ./...
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
# image ships and the alpine one does not.
# golang:1.x, YYYY-MM-DD
# Test phase
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS test
WORKDIR /src
COPY go.mod go.sum ./
@@ -199,29 +191,15 @@ style conventions are in separate documents:
FROM golang@sha256:... AS builder
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# The VERSION build arg when one is given, otherwise
# `git describe --tags --always` on the .git in the build context. With
# .git present, a version that is still empty, dev or unknown fails the
# build: git is missing or could not read the checkout.
ARG VERSION
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \
exit 1 ;; \
esac; \
fi; \
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage, and the last one
FROM alpine@sha256:...
@@ -243,41 +221,10 @@ style conventions are in separate documents:
(e.g. a web frontend compiled in a separate stage), the lint phase must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
- If the project requires CGO or system libraries for linting, install them
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
has no `apk`, so install with `apt-get` under the Debian package name
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
lists in the same `RUN`, so the layer does not keep them:
```dockerfile
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/*
```
- `.dockerignore` lets `.git` into the build context. It keeps out every git
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
repository's own, each submodule's under `.git/modules/`, and that of a
submodule keeping its own `.git` directory. `git describe` does not need
them, and each can hold a credential: a password in a remote URL, or the
token the CI checkout step stores there. A submodule whose name has a
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
git directory to `**/.git/modules/**/config`, and Go's version stamping
then fails the build: give it a name without that segment
(`git submodule add --name`). The stage that compiles has `git` (the
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
takes the version from the `VERSION` build argument when one is given,
otherwise from `git describe --tags --always`. That gives the tag on a
tagged commit; on a later commit, the tag, the number of commits since it
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
tag is reachable. The stage that compiles also marks its working directory
safe for git (`git config --system --add safe.directory /src`): a context
sent as a tar stream keeps the sender's file owners, and git refuses a
checkout owned by another user, so the version would come out empty.
`ARG VERSION` has no default, and the build fails if the context carries
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint phase with `apk add`.
- `ARG VERSION=dev` is declared in the stage that compiles and supplied by
`script/docker` and `script/cibuild`; no stage may call `git describe`.
- 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.
@@ -286,12 +233,7 @@ style conventions are in separate documents:
carry the same guarantee, because its gate phases may come from the cache. The
image build is uncached and so runs the gate phases a second time. That is the
price of the rule above, and it is worth paying: the image that ships is built
from a run of its own gates rather than from a cache entry. 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.
from a run of its own gates rather than from a cache entry.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -344,19 +286,17 @@ style conventions are in separate documents:
```
`-count=1` is required on both invocations: it defeats Go's test _result_
cache, so neither run can report a stored pass in place of running the
tests. It leaves the build cache alone, so it costs the runtime of the suite
and no recompilation.
cache, so the target cannot report a pass it did not earn, and the rerun
reproduces a failure instead of replaying it. It leaves the build cache
alone, so it costs the runtime of the suite and no recompilation.
That cache is Go's own, separate from Docker's layer cache. Go stores a
passing result in its cache directory (`GOCACHE`), and when the same tests
run again on unchanged code it prints that result, marked `(cached)`,
without running them. That matters on a developer's machine, where this
target runs and the directory lasts from one run to the next. The `test`
phase of the `Dockerfile` needs no `-count=1`: its base image holds no
result for this repo's tests and nothing before its `go test` step runs a
test, so there is nothing to replay. `--no-cache` (above) is what makes that
step run on an unchanged tree.
Note that this is a second, independent cache, stacked below the Docker
layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26)
addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes;
it does not guarantee `go test` inside that step does any work, because the
`GOCACHE` baked into earlier image layers survives into the re-executed
step. They are two separate defects requiring two separate fixes, and a fix
for one must not be recorded as covering the other.
Python example:
@@ -400,7 +340,7 @@ style conventions are in separate documents:
— which is more dangerous than a short file with no secret patterns at all,
because it reads as solved and stops anyone looking. Give every
depth-independent pattern the `**/` prefix and leave only genuinely
root-anchored entries unprefixed: `.claude`, and the repo's own host-built
root-anchored entries unprefixed: `.git`, and the repo's own host-built
binary, written `/myapp` and never `**/myapp`, which would also match
`cmd/myapp/` and delete the package directory from the context. Matching is
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
@@ -425,13 +365,12 @@ style conventions are in separate documents:
directory, so a repo running agents in subdirectories still ships
`services/api/.claude/` and must add its own anchored entry there.
- **A plain `docker build .` of a clone stamps the version that
`git describe --tags --always` gives**, derived from the `.git` in the build
context as the canonical `Dockerfile` above shows. Without its failure check,
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
and the build would still exit 0. `script/docker` and `script/cibuild` pass
the version they compute on the host; it takes precedence. They do this
byte-identically across repos:
- **Excluding `.git` means `git describe` cannot run inside any build stage, and
it fails quietly there.** In a build stage there is no repository, so
`git describe` writes nothing to stdout, `-X main.Version=` comes out empty,
the binary reports no version at all, and the build still exits 0. Compute the
version on the host and thread it in as a build arg. `script/docker` and
`script/cibuild` do this, byte-identically across repos:
```sh
# Own line: a failing command substitution inside an argument does not
@@ -448,7 +387,7 @@ style conventions are in separate documents:
fallback is applied — a live check that fires on a build from an export with
no `.git` and on a repository with no commits yet. Do not fold it into the
substitution as `|| echo unknown`, which makes the guard unreachable. The
Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
Dockerfile's side is `ARG VERSION=dev` in the stage that compiles, declared
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
the scripts stay byte-identical. One consequence for CI: the standard
@@ -487,18 +426,12 @@ style conventions are in separate documents:
`test-support` depguard rule, where a repo names its own test-support packages
by full import path. A repo adds entries there and changes nothing else, and a
re-vendor carries its entries forward. The canonical golangci-lint version is
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
v2.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base
image
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
directive must not name a newer Go minor version than the one golangci-lint
was built with, or golangci-lint refuses to lint it: this release lints
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
installs golangci-lint on the host. A repo sets the lint phase digest to the
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
the two prompted the change: the canonical copy can name linters that an older
golangci-lint rejects, and a newer golangci-lint can add linters that
`default: all` switches on until the canonical copy disables them.
(`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`,
which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the
only pin, since no repo installs golangci-lint on the host: bumping the
version means changing it and nothing else.
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
@@ -522,11 +455,6 @@ style conventions are in separate documents:
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
A Go tool a repo needs on the host is installed with `go install` pinned to
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
a `go.mod` tool dependency or through a `tools.go` file, either of which
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).
@@ -639,10 +567,10 @@ style conventions are in separate documents:
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
and language-specific config). Everything else goes in a subdirectory.
Canonical subdirectory names:
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
body is a single call into `internal/` or `pkg/`, no project logic in
@@ -673,7 +601,3 @@ style conventions are in separate documents:
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
is never committed under a file or directory named after one agent tool, such
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
+3 -253
View File
@@ -1,15 +1,12 @@
# Workflow
- branch from `next`
- branch (from `main`)
- do the work in Next Step
- move Next Step to the top of Completed Steps
- move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work)
- merge to `main` if the branch is not protected, otherwise open a PR
- push
- open a pull request that targets `next`
- once the pull request has passed review, the repository manager squash-merges
it into `next`
- only sneak merges `next` into `main`
# Status
@@ -17,260 +14,13 @@ pre-1.0
# Next Step
None: no implementation work is open. The cache design,
https://git.eeqj.de/sneak/quak/issues/36, waits on sneak's review.
None: every issue still open is done on `next` and waits for it to reach `main`.
Tagging and releases are decided by sneak alone, and happen only when he
declares one.
# Completed Steps
- 2026-10-14: `lib.backup()`, and so `quak backup`, works on up to the content
pool's limit of originals at once, 5 by default, instead of one at a time
(issue 182). That many workers take files from one shared list, each doing all
the work for one original, its `--verify` hashing included, so no more
originals than that are open at once. `BackupLibrary` says the limit as
`contentConcurrency`. A test holds each download at the fake Ente server to
count those in flight. Each worker checks the backup's lock before it takes
the next file, so once the lock is lost no further original starts.
- 2026-10-14: A backup whose lock is lost no longer dies with a stack trace
(issue 184). `lockBackupDirectory` gives proper-lockfile an `onCompromised`
that records why the lock was lost instead of throwing from its timer, and
returns the lock, which `quak backup` passes to `lib.backup()` as `lock`. The
backup then stops before it starts another file with
`lost the lock on DIR: REASON`, which `quak backup` prints as one line before
it exits 1. Releasing a lost lock leaves `backup.lock` alone. Tests in
`test/cli/backup-end-to-end.test.ts` remove the lock, or put another backup's
in its place, while a download is held open.
- 2026-10-14: An album or file quak cannot decrypt, or whose metadata is not
JSON, no longer fails the refresh, so one bad record no longer stops
`quak backup` from storing anything (issue 181). The refresh leaves it out, an
album with its files, and the library keeps it with the reason in
`metadata.json` until the server sends a copy that decrypts or deletes it.
`quak backup` stores every other file, records each such file in
`failures.json` with an error starting `could not be decrypted:`, names each
such album's ID in a printed line, or with `--json` in `undecryptableAlbums`,
and exits 1 while any remain. A file whose copy in another album decrypts is
not recorded: its original is stored from that copy, and the album where it
does not decrypt neither links nor lists it. The read commands answer from
what decrypted.
- 2026-10-14: `quak backup` no longer asks the server for changes every 3
seconds while it runs (issue 183). It opens its library with
`refreshIntervalSeconds: Infinity`, which the library now takes as no
background timer, so the backup refreshes only as it starts and no timed
refresh starts another ML data pass during the run. A test holds a backup's
download open while two hours pass and counts its `/collections/v2` requests.
- 2026-10-14: `test/cli/backup-end-to-end.test.ts` runs `quak backup` as cron
does (issue 186): `backupCommand` and `run`, the real `loadSession` reading a
session saved after logging in, and every request answered by a fake of the
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
under 50 GiB free (issue 160). It opens its libraries with
`freeBelowBytes: 0`, so the free space of the disk it runs on cannot shrink
the cache, evict the original it cached and add a fetch to the count. No other
test's result depends on that free space. The 50 GiB default is unchanged.
- 2026-10-02: `docker build .` stamps the commit's tag or short commit, not
`dev` (issue 154). `script/build` writes the version `script/version` prints
into `dist/package.json`: the `VERSION` environment variable or build arg when
one is given, otherwise `git describe --tags --always`, otherwise the version
`package.json` declares. `.dockerignore` sends `.git`, and a checkout with
`.git` whose version comes out empty, `dev` or `unknown` fails the build.
`make build-bin` bundles the built `dist/`, so the single binary reports the
same version.
- 2026-10-02: `photo.exif()` returns every EXIF tag in the file as `ExifTags`,
keyed by tag name, each as exifreader decodes it, not only the thirteen common
fields (issue 156). The embedded thumbnail's tags are under `Thumbnail`,
without the thumbnail image. The thirteen typed methods stay, each picking its
field from the tags `exif()` returns, typed as in `PhotoExif`. The example
script's JSON files now carry every tag.
- 2026-10-01: The content cache no longer looks for a live photo that an earlier
version cached as one ZIP (issue 151). When the cache opens, a live photo's
file that no JSON file names is now always left alone.
- 2026-10-01: `examples/download-albums.ts` logs in, opens the library, and for
every album downloads each photo to its save path, writes the photo's record
and EXIF fields to a JSON file beside it, and writes the album's photos to
`albums/<collectionID>.json` (issue 144). The build compiles it to
`dist/examples/`; the README's "Examples" section says how to run it.
- 2026-10-01: A `Photo` has one async method for each field of `exif()`, named
and typed as in `PhotoExif`: `make()`, `model()`, `lensModel()`,
`dateTimeOriginal()`, `offsetTimeOriginal()`, `exposureTime()`, `fNumber()`,
`iso()`, `focalLength()`, `orientation()`, `gpsLatitude()`, `gpsLongitude()`
and `gpsAltitude()` (issue 148). Each calls `exif()` and returns its one
field, or undefined when the file lacks it. `Photo` implements a type with one
method per `PhotoExif` field, so the build's type check fails when a field has
no method. A test checks, on the JPEG and the HEIC, that each method gives the
same value as `exif()`.
- 2026-10-01: Each original's save path is
`YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>` under the library's
download directory, which defaults to `photos` in the working directory (issue
143). The date is the photo's `takenAt` in the machine's time zone.
`photo.savePath` is always a string, with or without a content cache, and
`isLocal` is true only when the original is at its save path.
`photo.download()` puts the original there, copied from the cache when the
cache holds it and fetched otherwise. `lib.backup()` does the same for each
file, writes each file's JSON beside its original, and links `collections/` to
the save paths.
- 2026-10-01: `photo.exif()` and `backup-metadata --exif` read EXIF from
HEIC/HEIF originals, a live photo's HEIC image included, as well as JPEG and
the other image formats `exifreader` reads (issue 145). `exifreader` replaces
`exif-reader` and the JPEG segment scan; `PhotoExif` is unchanged. The `exif`
field of `backup-metadata --exif` is now exifreader's tag output, and
`exifRaw` holds the whole EXIF block it could not read. The tests use a real
HEIC, `test/exif.heic`.
- 2026-10-01: A `Photo` has `savePath`, `isLocal`, `content()`, `exif()`,
`modifiedAt`, `hash` and `year` (issue 141). `savePath` is where
`lib.backup()` writes the original under the library's download directory; for
a live photo not yet stored, it carries the title's extension, and the backup
may store the image under a different one. `isLocal` says whether the whole
original is there. Both look only at the disk. `content()` returns the
original's bytes and `exif()` the common EXIF fields of a JPEG; both may
download the original, and `exif()` downloads no video. `PhotoRecord` gains
`modifiedAt` and `hash`, and the JPEG EXIF scan moved to `src/exif.ts`.
- 2026-09-29: The "Workflow" list at the top of this file now says to branch
from `next` and open a pull request that targets `next`, that the repository
manager squash-merges a reviewed pull request into `next`, and that only sneak
merges `next` into `main`, as the README does (issue 137).
- 2026-09-29: The README's "Development workflow" and "For LLMs" sections now
say work branches from `next`, every pull request targets `next`, the
repository manager squash-merges reviewed pull requests into `next`, and only
sneak merges `next` into `main` (issue 135).
- 2026-09-29: Brought the README and this file in line with `next` after the
milestone merge (issue 132). The Next Step says no implementation work is open
and the cache design (issue 36) waits on sneak's review. README corrections:
the Getting Started comments say when the library refreshes; most, not all,
Makefile targets call a script; `script/lint` and `script/test` have no host
path, though `yarn test` does; the SRP handshake uses `fast-srp-hap`, outside
`crypto/`, and a thumbnail upload's MD5 uses `node:crypto`; the SRP password
is the first 16 bytes of a 32-byte subkey; the key attributes and token come
after SRP, after the TOTP code SRP may ask for, or after the email OTP that
replaces SRP when the account has email MFA on, and quak cannot answer a
passkey; the auth token is sent as URL-safe base64 with padding; every
`TypeError` is retried; each download attempt writes its own temporary files,
two for a live photo, and only the attempt that completes renames them into
place; `runMetadataBackup` records a failed download in the file's JSON; a
second `client.logout()` does not throw; `quak logout` with no session exits
0, and names the cache directory only when it knows it; `login`, `whoami` and
`logout` open no library; `--exif` records XMP and, for a JPEG, EXIF, and no
IPTC; which commands take `--json`; a failed ML data request may not have been
retried; the thumbnail fixer is not limited to baseline JPEG; `quak backup`
still fetches ML data; a backup's JSON holds the basic metadata fields quak
keeps and the private and public magic metadata, not every decrypted field,
and the README no longer lists what the magic metadata holds; the default
cache directory is the per-user one, not an XDG path on macOS; pinned
originals can exceed `cacheOriginalsMaxBytes`; which tests cover which
operations; a default read is only as current as the last refresh whose
requests all succeeded; `fresh()` and `lib.backup()` join a refresh already
running; a `Photo` has no `thumbnailPath` or `originalPath`; and `408` and
`429` are retried.
- 2026-09-28: Tested the live-photo writer's fsyncs (issue 130). A test checks
that the image's and the video's temp files are fsynced before either is
renamed into place, and the directory after both renames, as the `writeAtomic`
+2 -19
View File
@@ -23,7 +23,6 @@ import { run as runCommand } from "../src/cli-run.js";
import { loadSession } from "../src/cli-session.js";
import { Client } from "../src/client.js";
import { VERSION } from "../src/index.js";
import { UNATTENDED_RETRY_OPTIONS } from "../src/retry.js";
const paths = envPaths("quak", { suffix: "" });
@@ -130,24 +129,8 @@ program
)
.argument("<dir>", "Output directory")
.option("--json", "Print result as JSON instead of human-readable summary")
.option(
"--verify",
"Re-hash stored originals and download again any that do not match",
)
// A backup usually runs from cron with nobody watching, so every request
// it makes retries for longer than the other commands' requests do.
.action((dir: string, opts: { json?: boolean; verify?: boolean }) =>
run(
backupCommand(
{
...context(),
loadSession: (path) =>
loadSession(path, { retry: UNATTENDED_RETRY_OPTIONS }),
},
dir,
opts,
),
),
.action((dir: string, opts: { json?: boolean }) =>
run(backupCommand(context(), dir, opts)),
);
const helper = program
-129
View File
@@ -1,129 +0,0 @@
// Download every album's photos to a directory, with each photo's metadata
// beside it, using only quak's public API. The README's "Examples" section
// describes the files it writes.
//
// yarn build
// QUAK_EMAIL=… QUAK_PASSWORD=… node dist/examples/download-albums.js [dir]
import { realpathSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join, relative } from "node:path";
import { stdin, stdout } from "node:process";
import { createInterface } from "node:readline/promises";
import { pathToFileURL } from "node:url";
import { Client, Library } from "../src/index.js";
// Write `text` to `path` unless the file already holds exactly that, so a
// second run rewrites nothing.
async function writeIfChanged(path: string, text: string): Promise<void> {
const current = await readFile(path, "utf-8").catch(() => undefined);
if (current !== text) await writeFile(path, text);
}
const pretty = (value: unknown): string =>
JSON.stringify(value, null, 2) + "\n";
// For every album in `lib`, download each photo to its save path, write the
// photo's metadata to `{savePath}.json`, and write the album's photos to
// `{dir}/albums/{collectionID}.json`. Returns how many photos it downloaded
// and how many were already at their save paths.
export async function downloadAlbums(
lib: Library,
dir: string,
): Promise<{ downloaded: number; alreadyLocal: number }> {
let downloaded = 0;
let alreadyLocal = 0;
// A photo in several albums is handled once.
const done = new Set<number>();
// fresh() waits for a refresh from the server and throws if it fails, so
// albums and photos added since the cache was last written are included.
const { albums } = await lib.fresh();
for (const album of albums.list()) {
const savePaths: string[] = [];
for (const photo of album.photos.list()) {
if (!done.has(photo.fileID)) {
done.add(photo.fileID);
if (photo.isLocal) alreadyLocal++;
else downloaded++;
await photo.download();
// The cache paths say where quak's cache keeps copies, not
// anything about the photo.
const record = { ...photo.record() };
delete record.thumbnailPath;
delete record.originalPath;
const exif = await photo.exif();
// savePath is read after download(): a live photo's names its
// image only once the image is stored.
await writeIfChanged(
`${photo.savePath}.json`,
pretty({ ...record, exif }),
);
}
savePaths.push(relative(dir, photo.savePath));
}
await mkdir(join(dir, "albums"), { recursive: true });
await writeIfChanged(
join(dir, "albums", `${album.collectionID}.json`),
pretty({
collectionID: album.collectionID,
name: album.name,
savePaths,
}),
);
}
return { downloaded, alreadyLocal };
}
// Ask for a login code on the terminal. Client.login calls this only when the
// account requires a code.
async function ask(question: string): Promise<string> {
const terminal = createInterface({ input: stdin, output: stdout });
try {
return await terminal.question(question);
} finally {
terminal.close();
}
}
async function main(): Promise<void> {
const email = process.env.QUAK_EMAIL;
const password = process.env.QUAK_PASSWORD;
if (!email || !password) {
console.error(
"Set QUAK_EMAIL and QUAK_PASSWORD to the account's email and password.",
);
process.exit(1);
}
const dir = process.argv[2] ?? "photos";
const client = await Client.login({
email,
password,
totp: () => ask("Two-factor code: "),
emailOTP: () => ask("Code sent to your email: "),
});
const lib = await Library.open({
client,
downloadDirectory: dir,
// As in `quak backup`: fetch only the originals this script saves,
// not every thumbnail and the recent originals into the cache too.
precacheThumbnails: false,
precacheOriginals: false,
});
try {
const { downloaded, alreadyLocal } = await downloadAlbums(lib, dir);
console.log(
`${downloaded} photos downloaded, ${alreadyLocal} already local, in ${dir}`,
);
} finally {
await lib.close();
}
}
// Run main() when node runs this file, not when a test imports it. argv[1] is
// the path as given, and import.meta.url has symlinks resolved.
const script = process.argv[1];
if (script && pathToFileURL(realpathSync(script)).href === import.meta.url) {
await main();
}
+2 -4
View File
@@ -36,7 +36,6 @@
"devDependencies": {
"@eslint/js": "9.38.0",
"@types/node": "22.18.13",
"@types/proper-lockfile": "4.1.4",
"eslint": "9.38.0",
"prettier": "3.8.1",
"typescript": "5.9.3",
@@ -47,11 +46,10 @@
"@inquirer/prompts": "8.5.2",
"commander": "14.0.3",
"env-paths": "4.0.0",
"exifreader": "4.46.0",
"exif-reader": "2.0.3",
"fast-srp-hap": "2.0.4",
"fflate": "0.8.3",
"jpeg-js": "0.4.4",
"libsodium-wrappers-sumo": "0.8.4",
"proper-lockfile": "4.1.2"
"libsodium-wrappers-sumo": "0.8.4"
}
}
+9 -23
View File
@@ -1,7 +1,7 @@
#!/bin/sh
# script/build: compile the TypeScript sources into dist/, stamp the version
# script/version prints into it, then verify that the artifacts package.json
# advertises are among the files the compiler actually wrote. tsc reports success by exit status alone and knows nothing
# script/build: compile the TypeScript sources into dist/, then verify that
# the artifacts package.json advertises are among the files the compiler
# actually wrote. tsc reports success by exit status alone and knows nothing
# about the manifest, so without this step a green build can still ship a
# package whose main, types or bin resolve to nothing. Our own extension to
# scripts-to-rule-them-all.
@@ -46,24 +46,13 @@ for (const bin of bins) {
}
# src/index.ts imports ../package.json for the version, which tsc copies to
# dist/package.json. The version script/version prints is written into that
# copy only; the repo's own package.json is left as it is.
stamp_version() {
node -e '
const { readFileSync, writeFileSync } = require("node:fs");
const pkg = JSON.parse(readFileSync("dist/package.json", "utf-8"));
pkg.version = process.argv[1];
writeFileSync("dist/package.json", JSON.stringify(pkg, null, 4) + "\n");
' "$1"
}
# Running the built CLI proves the import resolves from dist/ and reports
# the stamped version.
# dist/package.json. Running the built CLI proves that import resolves from
# dist/ and reports the version package.json declares.
verify_version() {
built="$(node dist/bin/quak.js --version)"
if [ "$built" != "$1" ]; then
echo "build: dist/bin/quak.js reports $built, the build stamped $1" >&2
declared="$(node -p 'require("./package.json").version')"
if [ "$built" != "$declared" ]; then
echo "build: dist/bin/quak.js reports $built, package.json declares $declared" >&2
exit 1
fi
echo "build: dist/bin/quak.js reports version $built"
@@ -71,12 +60,9 @@ verify_version() {
main() {
cd "$ROOT"
# Own line, so that a failing script/version stops the build.
version="$("$ROOT/script/version")"
yarn run tsc
stamp_version "$version"
verify_entrypoints
verify_version "$version"
verify_version
}
main "$@"
+7 -5
View File
@@ -1,8 +1,11 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. test and lint are Docker
# phases; fmt-check is native, because a formatter writes the working
# tree. Must not modify any files.
# script/check: run all checks (test, lint). Our own extension to
# scripts-to-rule-them-all. Both are Docker phases. Must not modify any
# files.
#
# script/fmt-check is not called here, unlike the template: the lint
# phase already runs `prettier --check .`, so calling it would run
# prettier a second time over the same tree for the same verdict.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -10,7 +13,6 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"
+7 -7
View File
@@ -1,7 +1,8 @@
#!/bin/sh
# script/cibuild: run the CI build. It bootstraps first: a CI runner
# checks out and runs this and nothing else, and script/fmt-check runs
# the formatter on the host, which a pristine checkout cannot do.
# script/cibuild: run the CI build. The image's last stage depends on the
# lint and test phases, so this one build runs eslint, prettier and the
# suite once each and then compiles. Unlike the template it does not run
# script/check first, which would run lint and the tests a second time.
# --no-cache for the same reason as script/docker: the gate phases the
# final stage depends on are RUN steps, and a cached one is a check that
# did not run.
@@ -12,12 +13,11 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/check"
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. The VERSION build argument takes precedence over
# the version a build stage derives from the .git in the context.
# empty constant. VERSION is computed here because .dockerignore
# excludes .git, so `git describe` in a build stage yields an empty
# version without failing.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
+3 -2
View File
@@ -12,8 +12,9 @@ main() {
cd "$ROOT"
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. The VERSION build argument takes precedence over
# the version a build stage derives from the .git in the context.
# empty constant. VERSION is computed here because .dockerignore
# excludes .git, so `git describe` in a build stage yields an empty
# version without failing.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
+1 -20
View File
@@ -4,28 +4,9 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() {
cd "$ROOT"
run_yarn run prettier --write .
yarn run prettier --write .
}
main "$@"
+1 -20
View File
@@ -4,28 +4,9 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt-check: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() {
cd "$ROOT"
run_yarn run prettier --check .
yarn run prettier --check .
}
main "$@"
+5 -5
View File
@@ -2,17 +2,17 @@
# script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all.
#
# Runs lint and fmt-check but deliberately NOT the tests, so the TDD
# red-phase commit (failing tests, no implementation yet) can land. CI
# runs script/cibuild, which runs the tests, and so catches any branch
# that ships red.
# Runs lint but deliberately NOT the tests, so the TDD red-phase commit
# (failing tests, no implementation yet) can land. CI runs
# script/cibuild, whose image build includes the test phase, and so
# catches any branch that ships red. The lint phase includes the
# prettier check, so a badly formatted tree still fails the commit.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"
-41
View File
@@ -1,41 +0,0 @@
#!/bin/sh
# script/version: print the version script/build stamps into the built
# package. Our own extension to scripts-to-rule-them-all.
#
# Order of precedence:
#
# 1. $VERSION, if set and not empty: an explicit value, such as the
# Dockerfile's VERSION build arg.
# 2. If this checkout has .git, `git describe --tags --always`: the tag
# on a tagged commit; the tag, the commits since it and the short
# commit on a later commit (v1.2.3-4-gabc1234); the short commit when
# no tag is reachable.
# 3. Otherwise, as in a source tarball, the version package.json declares.
#
# A checkout with .git whose version still comes out empty, dev or unknown
# fails: git is missing or could not read the checkout, and the build could
# not be traced back to its commit.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
version="${VERSION:-}"
if [ -e .git ]; then
if [ -z "$version" ]; then
version="$(git describe --tags --always || true)"
fi
case "$version" in
"" | dev | unknown)
echo "version: $ROOT has .git, but the version came out '$version'" >&2
exit 1
;;
esac
elif [ -z "$version" ]; then
version="$(node -p 'require("./package.json").version')"
fi
echo "$version"
}
main "$@"
+174 -555
View File
@@ -1,65 +1,42 @@
// The backup command, rebuilt on the library API (issue #51).
//
// `lib.backup()` takes the lock in `downloadDirectory` (unless its caller
// passes one in), and fails at once when another backup of it holds the
// lock; when the lock is lost while it runs, it fails before it starts another
// file. It waits for a completed refresh of the library (a failed one fails the
// backup before any file is touched), then, for every file in scope, puts its
// original at its save path under `downloadDirectory`, as `Photo.download()`
// does, up to the content pool's limit of them at once, waits for an ML data
// fetch, and rebuilds the derived views (per-file sidecars, per-collection
// symlink trees, per-collection JSON) from the model. The on-disk layout:
// `lib.backup()` waits for a completed refresh of the library (a failed one
// fails the backup before any file is touched), then, for every file in scope,
// gets its original bytes onto disk under `downloadDirectory` and rebuilds the
// derived views (per-file sidecars, per-collection symlink trees,
// per-collection JSON) from the model. The on-disk layout is the historical
// one:
//
// <downloadDirectory>/
// YYYY/YYYY-MM/YYYY-MM-DD/
// YYYY-MM-DD.<fileID>.<ext> the decrypted bytes (the save path)
// YYYY-MM-DD.<fileID>.json per-file metadata sidecar, with
// the file's ML data and its
// original's EXIF, XMP and
// dimensions
// collections/<name>/<title> symlink to the original
// collections/<name>.json per-collection metadata
// account.json the account's email and user ID
// backup.lock the lock, while a backup runs
// failures.json durable ledger of unresolved failures
// originals/<fileID>.<ext> the decrypted bytes
// originals/<fileID>.json per-file metadata sidecar
// collections/<name>/<title> symlink into ../../originals
// collections/<name>.json per-collection metadata
// failures.json durable ledger of unresolved failures
//
// A live photo's original is its image and its video, each with its own
// extension, beside `YYYY-MM-DD.<fileID>.livephoto.json` naming them; its album
// folders link both.
// A live photo's original is its image and its video, `<fileID>.<ext>` each
// with its own extension, and `originals/<fileID>.livephoto.json` naming them;
// its album folders link both.
//
// Crash-safety rests on two properties. Bytes are present-means-complete: an
// original appears at its save path only via the content layer's atomic
// original appears under `originals/` only via the content layer's atomic
// temp-then-rename, so a file that exists is whole and is never re-fetched — an
// interrupted run resumes by looking at the save paths. The derived views hold
// no unique state, so they are rebuilt every run; that repairs stale sidecars
// and missing or broken symlinks left by an earlier crash. A rebuild also
// removes the symlinks to originals that no longer belong to an album, and the
// directories of albums that no longer exist. 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.
// interrupted run resumes by listing the directory. The derived views hold no
// unique state, so they are rebuilt every run; that repairs stale sidecars and
// missing or broken symlinks left by an earlier crash. A rebuild also removes
// the symlinks into originals/ that no longer belong to an album, and the
// directories of albums that no longer exist.
//
// Resilience (issue #8): no per-file condition aborts the run. A failed
// download, a failed symlink, or ML data missing because the ML data fetch
// failed is caught, recorded in `failures.json` with a classification, a
// running attempt count, and the last-tried time, and the run continues.
// A file the library could not decrypt is recorded there too, unless a copy of
// it in another album decrypted; an album it could not decrypt is logged by ID
// and listed in `result.undecryptableAlbums`, as its name is not known.
// `result.failed` — and thus the CLI's exit code — stays non-zero while any
// failure remains unresolved and clears once every one succeeds. Each run
// reconciles the ledger against the files it attempted, so an entry for a file
// that has since left the library (deleted) or this run's scope is dropped
// rather than counted forever, which would poison a scheduled backup's exit
// code.
// download or a failed symlink is caught, recorded in `failures.json` with a
// classification, a running attempt count, and the last-tried time, and the run
// continues. `result.failed` — and thus the CLI's exit code — stays non-zero
// while any failure remains unresolved and clears once every one succeeds. Each
// run reconciles the ledger against the files it attempted, so an entry for a
// file that has since left the library (deleted) or this run's scope is dropped
// rather than counted forever, which would poison a scheduled backup's exit code.
import {
createReadStream,
lstatSync,
mkdirSync,
readdirSync,
@@ -71,44 +48,24 @@ import {
symlinkSync,
writeFileSync,
} from "node:fs";
import { readFile } from "node:fs/promises";
import { dirname, extname, join, relative, resolve } from "node:path";
import lockfile from "proper-lockfile";
import { copyFile, rename, rm } from "node:fs/promises";
import { basename, dirname, extname, join, relative } from "node:path";
import {
chunkHashFinal,
chunkHashInit,
chunkHashUpdate,
init,
} from "./crypto/index.js";
import { removeLeftoverTempFiles } from "./download/index.js";
import { fsyncPath, removeLeftoverTempFiles } from "./download/index.js";
import { sanitizeFileName, withExtension } from "./filename.js";
import {
copyAtomic,
placeOriginal,
savePath,
storedAtSavePath,
nameInOriginals,
storedOriginal,
writeLivePhotoJSON,
} from "./library/content.js";
import { representative } from "./library/records.js";
import type { Undecryptable } from "./library/store.js";
import { extractImageMetadata } from "./metadata-backup.js";
import type { MLData } from "./mldata-fetch.js";
import type { Collection, EnteFile, FileMetadata } from "./model/types.js";
import type { Collection, EnteFile } from "./model/types.js";
export type ProgressCallback = (message: string) => void;
// The lock in a backup directory, as `lockBackupDirectory` returns it.
export interface BackupLock {
// Removes `backup.lock`, unless the lock was lost.
release(): Promise<void>;
// Why the lock was lost, once it is.
lost?: Error;
}
export interface BackupOptions {
// Where the backup tree lives. `lib.backup()` defaults it to the library's
// download directory; `runBackup` with none throws before any network
// traffic.
// Where the backup tree lives. Required: with none, `backup()` throws
// before any network traffic. A library opened with a `downloadDirectory`
// supplies the default.
downloadDirectory?: string;
// Fetch and store full-resolution originals. Default true.
includeOriginals?: boolean;
@@ -117,15 +74,7 @@ export interface BackupOptions {
includeThumbnails?: boolean;
// Restrict the backup to albums with these names; others are left untouched.
onlyAlbumNames?: string[];
// Hash each original already at its save path, and fetch again any whose
// bytes do not match the content hash its metadata records. Default false.
verify?: boolean;
onProgress?: ProgressCallback;
// The lock in `downloadDirectory`, when the caller took it with
// `lockBackupDirectory` and releases it itself. The backup then does not
// take the lock, and fails once this one is lost. `quak backup` takes it
// before it opens its library.
lock?: BackupLock;
}
export interface BackupError {
@@ -140,57 +89,32 @@ export interface BackupResult {
totalFiles: number;
// Originals fetched (or copied from the cache) this run.
downloaded: number;
// Originals already at their save path and left untouched.
// Originals already present and left untouched.
skipped: number;
// With `verify`, the originals already at their save path whose hash
// matched, those whose hash did not (each removed and fetched again), and
// those whose metadata records no hash (left as they are). All three are
// zero without `verify`.
verified: number;
mismatched: number;
unchecked: number;
// Files with an unresolved failure after this run (the ledger size); the
// CLI exits non-zero while this is above zero. A file can be both
// downloaded and failed if its bytes landed but its symlink did not.
failed: number;
// This run's per-file errors, in encounter order.
errors: BackupError[];
// The IDs of the albums the library could not decrypt, whose files it
// could not list; the CLI exits non-zero while there are any.
undecryptableAlbums: number[];
}
// The slice of the library that backup drives. `Library` implements it; a test
// can drive backup with a stand-in.
export interface BackupLibrary {
// The account the library belongs to.
whoami(): { email: string; userID: number };
refresh(): Promise<void>;
listCollections(): Collection[];
listFiles(collectionID: number): EnteFile[];
// The albums, and files in albums, that the library could not decrypt,
// which `listCollections` and `listFiles` leave out.
undecryptable(): Undecryptable[];
// Get an original's bytes onto disk through the content cache/pools,
// returning where they landed: `destination` when they were fetched now,
// otherwise wherever they already were (the cache, or the library's save
// path). A live photo lands as its image and its video, fetched now beside
// otherwise wherever they already were (the cache, or a prior backup). A
// live photo lands as its image and its video, fetched now beside
// `destination`.
original(
fileID: number,
destination: string,
): Promise<{ path: string; videoPath?: string }>;
thumbnail(fileID: number): Promise<{ path: string }>;
// How many originals the backup works on at once: the limit of the
// library's content pool.
contentConcurrency: number;
// Wait for an ML data fetch to complete, joining one already running or
// starting one. Rejects with the reason when it fails; resolves at once
// when the library cannot fetch ML data.
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";
@@ -244,6 +168,58 @@ const classify = (err: unknown): FailureClass => {
const errorMessage = (err: unknown): string =>
err instanceof Error ? err.message : String(err);
// Copy bytes into `dest` via a temp file in the same directory plus rename, so
// `dest` appears only once it is whole ("present means complete"). As in the
// download writer, the temp file is fsynced before the rename and the directory
// after it, so a power cut cannot leave a correctly named but short original.
// The temp name carries this process's ID so a later run can tell a leftover
// from a copy still in progress (see `removeLeftoverTempFiles`).
const copyAtomic = async (src: string, dest: string): Promise<void> => {
if (src === dest) return;
const tmp = join(
dirname(dest),
`.quak-backup-${basename(dest)}-${process.pid}-${Math.random()
.toString(36)
.slice(2)}.tmp`,
);
try {
await copyFile(src, tmp);
await fsyncPath(tmp);
// `rename` replaces the destination's directory entry: an existing
// symlink at `dest` is replaced, not followed, and the new file has
// the temp file's permissions (copied from `src`).
await rename(tmp, dest);
await fsyncPath(dirname(dest));
} finally {
await rm(tmp, { force: true });
}
};
// Put an original the library returned at `dest` in originals/, where a fresh
// fetch already wrote it. A live photo's image and video go beside `dest`: when
// they came from the cache they are copied, after removing whatever was at
// `dest` (an earlier version's ZIP of the two). Then the JSON file naming them
// is written, which is what makes the live photo count as stored.
const placeOriginal = async (
file: EnteFile,
dest: string,
got: { path: string; videoPath?: string },
): Promise<void> => {
if (got.videoPath === undefined) {
await copyAtomic(got.path, dest);
return;
}
const originalsDir = dirname(dest);
const path = join(originalsDir, basename(got.path));
const videoPath = join(originalsDir, basename(got.videoPath));
if (got.path !== path) {
await rm(dest, { force: true });
await copyAtomic(got.path, path);
await copyAtomic(got.videoPath, videoPath);
}
await writeLivePhotoJSON(originalsDir, file.id, { path, videoPath });
};
// Ensure `linkPath` is a symlink to `target`, rebuilding a missing, wrong, or
// non-symlink entry. Throws on failure (a directory in the way, no permission)
// so the caller records it and moves on rather than aborting the run.
@@ -315,55 +291,36 @@ const linksFor = (
}));
};
// Every date folder (`YYYY/YYYY-MM/YYYY-MM-DD/`) under `root`, whether or not a
// file in this backup is saved there. A folder that cannot be read is skipped.
const dateFolders = (root: string): string[] => {
const subfolders = (dir: string, name: RegExp): string[] => {
try {
return readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory() && name.test(e.name))
.map((e) => join(dir, e.name));
} catch {
return [];
}
};
return subfolders(root, /^\d{4}$/)
.flatMap((year) => subfolders(year, /^\d{4}-\d\d$/))
.flatMap((month) => subfolders(month, /^\d{4}-\d\d-\d\d$/));
};
// Whether the entry at `path` is a symlink a backup to `root` made: one to an
// original in a `YYYY/YYYY-MM/YYYY-MM-DD/` folder of `root`.
const linksToOriginal = (path: string, root: string): boolean => {
if (!lstatSync(path).isSymbolicLink()) return false;
const target = relative(root, resolve(dirname(path), readlinkSync(path)));
return /^\d{4}\/\d{4}-\d\d\/\d{4}-\d\d-\d\d\/[^/]+$/.test(target);
};
// Remove the symlinks in the album directory `dir` that point to an original
// in `root` and are not named in `keep`. Nothing else in the directory is
// touched: anything else there was put there by the user.
// Remove the symlinks in the album directory `dir` that point into
// `originalsDir` and are not named in `keep`. Nothing else in the directory
// is touched: anything else there was put there by the user.
const removeStaleLinks = (
dir: string,
keep: Set<string>,
root: string,
originalsDir: string,
): void => {
const target = relative(dir, originalsDir);
for (const name of readdirSync(dir)) {
if (keep.has(name)) continue;
const path = join(dir, name);
if (linksToOriginal(path, root)) rmSync(path);
if (
lstatSync(path).isSymbolicLink() &&
dirname(readlinkSync(path)) === target
) {
rmSync(path);
}
}
};
// Remove the directories under `collectionsDir` that an earlier run wrote for
// an album that is gone or renamed: a directory not named in `current` with a
// `<name>.json` beside it holding an album ID, which is what a run writes. Its
// symlinks to originals in `root` are removed; if that leaves it empty, it and
// its JSON are deleted, otherwise both stay for what the user put there.
// symlinks into originals/ are removed; if that leaves it empty, it and its
// JSON are deleted, otherwise both stay for what the user put there.
const removeStaleAlbumDirs = (
collectionsDir: string,
current: Set<string>,
root: string,
originalsDir: string,
): void => {
for (const entry of readdirSync(collectionsDir, { withFileTypes: true })) {
if (!entry.isDirectory() || current.has(entry.name)) continue;
@@ -377,7 +334,7 @@ const removeStaleAlbumDirs = (
continue;
}
const dir = join(collectionsDir, entry.name);
removeStaleLinks(dir, new Set(), root);
removeStaleLinks(dir, new Set(), originalsDir);
if (readdirSync(dir).length > 0) continue;
rmdirSync(dir);
rmSync(jsonPath);
@@ -414,345 +371,136 @@ const saveLedger = (path: string, ledger: Map<number, FailureEntry>): void => {
);
};
// The content hash of the original stored at `stored`, computed as the download
// check computes it: over each file's bytes, read in chunks, and for a live
// photo `<imageHash>:<videoHash>`.
const storedHash = async (stored: {
path: string;
videoPath?: string;
}): Promise<string> => {
await init();
const hashFile = async (path: string): Promise<string> => {
const state = chunkHashInit();
for await (const chunk of createReadStream(path)) {
chunkHashUpdate(state, chunk as Buffer);
}
return chunkHashFinal(state);
};
const hash = await hashFile(stored.path);
if (stored.videoPath === undefined) return hash;
return `${hash}:${await hashFile(stored.videoPath)}`;
};
// A file's EXIF, XMP and dimensions as its JSON holds them: what
// `extractImageMetadata` found in its original, or why the original could not
// be read.
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 writeSidecar = (path: string, file: EnteFile): void => {
const meta: Record<string, unknown> = {
id: file.id,
collectionID: file.collectionID,
ownerID: file.ownerID,
metadata: file.metadata,
updationTime: file.updationTime,
};
if (file.magicMetadata) meta.magicMetadata = file.magicMetadata;
if (file.pubMagicMetadata) meta.pubMagicMetadata = file.pubMagicMetadata;
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));
};
// 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 `lock` is held. Once
// the lock is lost, another backup may be writing to `downloadDirectory`, so
// this one fails before it starts another file.
const runLockedBackup = async (
export const runBackup = async (
lib: BackupLibrary,
opts: BackupOptions,
downloadDirectory: string,
lock: BackupLock,
): Promise<BackupResult> => {
const downloadDirectory = opts.downloadDirectory;
if (!downloadDirectory) {
throw new Error(
"backup requires a downloadDirectory (pass one to backup() or " +
"open the library with one)",
);
}
const includeOriginals = opts.includeOriginals ?? true;
const includeThumbnails = opts.includeThumbnails ?? false;
const log = opts.onProgress ?? (() => {});
const only = opts.onlyAlbumNames ? new Set(opts.onlyAlbumNames) : undefined;
const verify = opts.verify ?? false;
log("Refreshing library...");
await lib.refresh();
if (lock.lost) throw lock.lost;
const originalsDir = join(downloadDirectory, "originals");
const collectionsDir = join(downloadDirectory, "collections");
const thumbnailsDir = join(downloadDirectory, "thumbnails");
mkdirSync(originalsDir, { 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 });
removeLeftoverTempFiles(originalsDir);
removeLeftoverTempFiles(thumbnailsDir);
for (const dir of dateFolders(downloadDirectory)) {
removeLeftoverTempFiles(dir);
}
const ledgerPath = join(downloadDirectory, "failures.json");
const ledger = loadLedger(ledgerPath);
const now = Date.now();
// Collections in scope, and the distinct files across them (a file shared
// by two albums is one original). Each file is the membership
// `representative` picks from all of its albums, in scope or not, so it is
// saved at the path `photo.savePath` names.
// by two albums is one original).
const allCollections = lib.listCollections();
const collections = allCollections.filter((c) =>
only ? only.has(c.name) : true,
);
const collectionName = new Map<number, string>();
for (const c of allCollections) collectionName.set(c.id, c.name);
for (const c of collections) collectionName.set(c.id, c.name);
const memberships = new Map<number, EnteFile[]>();
const distinct = new Map<number, EnteFile>();
const filesByCollection = new Map<number, EnteFile[]>();
for (const c of allCollections) {
for (const c of collections) {
const files = lib.listFiles(c.id);
filesByCollection.set(c.id, files);
for (const f of files) {
const arr = memberships.get(f.id);
if (arr) arr.push(f);
else memberships.set(f.id, [f]);
}
}
const distinct = new Map<number, EnteFile>();
for (const c of collections) {
for (const f of filesByCollection.get(c.id)!) {
if (!distinct.has(f.id)) {
distinct.set(f.id, representative(memberships.get(f.id)!));
}
}
for (const f of files) if (!distinct.has(f.id)) distinct.set(f.id, f);
}
const errors: BackupError[] = [];
const failedThisRun = new Set<number>();
const storedThisRun = new Set<number>();
let downloaded = 0;
let skipped = 0;
let verified = 0;
let mismatched = 0;
let unchecked = 0;
const recordFailure = (
fileID: number,
title: string,
file: EnteFile,
collection: string,
err: unknown,
): void => {
// Count at most one attempt per file per run: a file whose original
// and thumbnail both fail this run must not double its attempt count
// or appear twice in errors.
if (failedThisRun.has(fileID)) return;
if (failedThisRun.has(file.id)) return;
const error = errorMessage(err);
errors.push({ fileID, title, collection, error });
const prior = ledger.get(fileID);
ledger.set(fileID, {
fileID,
title,
errors.push({
fileID: file.id,
title: file.metadata.title,
collection,
error,
});
const prior = ledger.get(file.id);
ledger.set(file.id, {
fileID: file.id,
title: file.metadata.title,
classification: classify(err),
attempts: (prior?.attempts ?? 0) + 1,
lastTriedAt: now,
error,
});
failedThisRun.add(fileID);
failedThisRun.add(file.id);
};
// 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. Fetch each pending original (and optional
// thumbnail) through the content cache/pools and place it under the backup
// tree; a present file is left as is.
if (includeOriginals) {
for (const [fileID, file] of distinct) {
if (storedOriginal(originalsDir, file) !== undefined) {
skipped++;
continue;
}
}
}
// Phase 1: get the bytes. Put each pending original at its save path
// through the content cache/pools, as `Photo.download()` does, and fetch
// the optional thumbnails; a present file is left as is. With `verify`, a
// present original is hashed first, and one that does not match the hash
// its metadata records is removed and fetched like a missing one. One
// that cannot be read is recorded as failed and left where it is.
// `storeOriginal` does this for one original, and records its failure.
const storeOriginal = async (file: EnteFile): Promise<void> => {
let stored = storedAtSavePath(downloadDirectory, file);
let mismatch = false;
if (stored !== undefined && verify) {
const dest = join(originalsDir, nameInOriginals(file));
try {
if (file.metadata.hash === undefined) {
unchecked++;
} else if ((await storedHash(stored)) === file.metadata.hash) {
verified++;
} else {
log(
`MISMATCH original ${file.metadata.title} (${file.id}): its bytes do not match its content hash`,
);
mismatched++;
mismatch = true;
rmSync(stored.path);
if (stored.videoPath !== undefined) {
rmSync(stored.videoPath);
}
stored = undefined;
}
log(`Fetching original ${file.metadata.title} (${fileID})...`);
// A fetched original is written straight to `dest` (a live
// photo beside it); only one that was already cached elsewhere
// is copied.
await placeOriginal(
file,
dest,
await lib.original(fileID, dest),
);
downloaded++;
} catch (err) {
log(
`FAILED verifying original ${file.metadata.title}: ${errorMessage(err)}`,
`FAILED original ${file.metadata.title}: ${errorMessage(err)}`,
);
recordFailure(
file.id,
file.metadata.title,
file,
collectionName.get(file.collectionID) ?? "",
err,
);
return;
}
}
if (stored !== undefined) {
skipped++;
return;
}
try {
log(`Fetching original ${file.metadata.title} (${file.id})...`);
// A fetched original is written straight to its save path (a
// live photo beside it); only one that was already cached
// elsewhere is copied.
const placed = await placeOriginal(
downloadDirectory,
file,
(dest) => lib.original(file.id, dest),
);
storedThisRun.add(file.id);
// A copy from the cache is not checked as a download is and can
// hold the same bad bytes, so what is put back after a mismatch
// is hashed too. A bad copy stays where it is and the file fails.
if (mismatch && (await storedHash(placed)) !== file.metadata.hash) {
throw new Error(
"the original put back does not match its content hash either",
);
}
downloaded++;
} catch (err) {
log(`FAILED original ${file.metadata.title}: ${errorMessage(err)}`);
recordFailure(
file.id,
file.metadata.title,
collectionName.get(file.collectionID) ?? "",
err,
);
}
};
// Up to the content pool's limit of originals are worked on at once,
// their `verify` hashing included, so however large the account is, no
// more than that many are open. Each worker takes the next file from the
// iterator they all share until none is left, and throws instead once the
// lock is lost. A worker that throws, as one also does when `onProgress`
// throws, fails the backup only once every worker has stopped, so none
// writes into the backup after it has ended.
if (includeOriginals) {
const pending = distinct.values();
const worker = async (): Promise<void> => {
for (const file of pending) {
if (lock.lost) throw lock.lost;
await storeOriginal(file);
}
};
const workers = await Promise.allSettled(
Array.from({ length: lib.contentConcurrency }, worker),
);
for (const w of workers) {
if (w.status === "rejected") throw w.reason;
}
}
if (includeThumbnails) {
for (const [fileID, file] of distinct) {
if (lock.lost) throw lock.lost;
const dest = join(thumbnailsDir, `${fileID}.jpg`);
if (isPresent(dest)) continue;
try {
@@ -760,8 +508,7 @@ const runLockedBackup = async (
await copyAtomic(path, dest);
} catch (err) {
recordFailure(
file.id,
file.metadata.title,
file,
collectionName.get(file.collectionID) ?? "",
err,
);
@@ -770,45 +517,11 @@ const runLockedBackup = async (
}
// Phase 2: rebuild the derived views from the model. Sidecars first, for
// every present original (this repairs stale ones), each with the file's
// ML data once an ML data fetch has completed, and its image metadata.
// When the fetch fails, a file with no cached ML data gets the reason
// instead and is recorded as failed. The next run fetches its ML data
// again because none is cached.
// every present original (this repairs stale ones).
if (includeOriginals) {
let mlDataError: string | undefined;
try {
log("Fetching ML data...");
await lib.fetchMLData();
} catch (err) {
mlDataError = errorMessage(err);
log(`FAILED ML data: ${mlDataError}`);
}
for (const file of distinct.values()) {
if (lock.lost) throw lock.lost;
const stored = storedAtSavePath(downloadDirectory, file);
if (stored === undefined) continue;
const path = withExtension(
savePath(downloadDirectory, file),
".json",
);
const image = await imageMetadataFor(
file,
stored.path,
path,
storedThisRun.has(file.id),
);
const mlData = await lib.mlData(file.id);
if (mlData === undefined && mlDataError !== undefined) {
writeSidecar(path, file, { mlDataError }, image);
recordFailure(
file.id,
file.metadata.title,
collectionName.get(file.collectionID) ?? "",
new Error(`ML data: ${mlDataError}`),
);
} else {
writeSidecar(path, file, { mlData }, image);
for (const [fileID, file] of distinct) {
if (storedOriginal(originalsDir, file) !== undefined) {
writeSidecar(join(originalsDir, `${fileID}.json`), file);
}
}
}
@@ -829,35 +542,24 @@ const runLockedBackup = async (
const albumDirNames = new Map(
allCollections.map((c, i) => [c.id, dirNames[i]!]),
);
if (lock.lost) throw lock.lost;
try {
removeStaleAlbumDirs(
collectionsDir,
new Set(dirNames),
downloadDirectory,
);
removeStaleAlbumDirs(collectionsDir, new Set(dirNames), originalsDir);
} catch (err) {
log(`FAILED removing old album directories: ${errorMessage(err)}`);
}
for (const c of collections) {
if (lock.lost) throw lock.lost;
const colDirName = albumDirNames.get(c.id)!;
const colDir = join(collectionsDir, colDirName);
mkdirSync(colDir, { recursive: true });
// Every album links the one original, saved from the file's entry in
// `distinct`.
const files = filesByCollection.get(c.id) ?? [];
const links = files.flatMap((f) =>
linksFor(
f,
storedAtSavePath(downloadDirectory, distinct.get(f.id)!),
),
linksFor(f, storedOriginal(originalsDir, f)),
);
const linkNames = uniqueNames(links, true);
try {
removeStaleLinks(colDir, new Set(linkNames), downloadDirectory);
removeStaleLinks(colDir, new Set(linkNames), originalsDir);
} catch (err) {
log(`FAILED removing old links in ${c.name}: ${errorMessage(err)}`);
}
@@ -878,19 +580,17 @@ const runLockedBackup = async (
log(
`FAILED symlink ${c.name}/${linkName}: ${errorMessage(err)}`,
);
recordFailure(
link.file.id,
link.file.metadata.title,
c.name,
err,
);
recordFailure(link.file, c.name, err);
}
}
writeAlbumJSON(
writeFileSync(
join(collectionsDir, `${colDirName}.json`),
c,
metaFiles,
JSON.stringify(
{ id: c.id, name: c.name, type: c.type, files: metaFiles },
null,
2,
),
);
}
@@ -899,9 +599,7 @@ const runLockedBackup = async (
// its failure resolved; a file gone from the library (deleted) or outside
// this run's scope is not something this run can resolve, so keeping its
// stale entry would keep the exit code non-zero forever — a single
// since-deleted photo would fail every future scheduled backup. A backup
// whose lock was lost leaves the ledger as it is.
if (lock.lost) throw lock.lost;
// since-deleted photo would fail every future scheduled backup.
for (const fileID of [...ledger.keys()]) {
if (!failedThisRun.has(fileID)) ledger.delete(fileID);
}
@@ -911,86 +609,7 @@ const runLockedBackup = async (
totalFiles: distinct.size,
downloaded,
skipped,
verified,
mismatched,
unchecked,
failed: ledger.size,
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 it; while another backup holds the lock, fails at once with an error
// whose `code` is `ELOCKED`. The lock is the directory `backup.lock`, whose
// modification time proper-lockfile keeps current while it is held. One it has
// not touched for 10 seconds was left by a run that could not remove it, and
// is taken over.
//
// proper-lockfile finds the lock lost when `backup.lock` is gone or is another
// backup's, and calls `onCompromised` from a timer, where throwing would end
// the process. The `onCompromised` given here sets the lock's `lost` instead,
// and the backup holding the lock stops before it starts another file (see
// `runLockedBackup`). Releasing a lost lock leaves `backup.lock` alone, since
// it may be another backup's.
export const lockBackupDirectory = async (
downloadDirectory: string,
): Promise<BackupLock> => {
mkdirSync(downloadDirectory, { recursive: true });
let release: () => Promise<void>;
const lock: BackupLock = {
release: async () => {
if (lock.lost === undefined) await release();
},
};
try {
release = await lockfile.lock(downloadDirectory, {
lockfilePath: join(downloadDirectory, "backup.lock"),
onCompromised: (err) => {
lock.lost = new Error(
`lost the lock on ${downloadDirectory}: ${err.message}`,
);
},
});
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err;
throw Object.assign(
new Error(`another backup of ${downloadDirectory} is running`),
{ code: "ELOCKED" },
);
}
return lock;
};
// Runs the backup holding the lock, which it takes and releases itself unless
// the caller passes the lock it took (`opts.lock`).
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)",
);
}
// As `BoundedPool` does. A limit below 1, or one that is not a number,
// would back up no original and end with no error.
const { contentConcurrency } = lib;
if (!Number.isInteger(contentConcurrency) || contentConcurrency < 1) {
throw new RangeError(
`contentConcurrency must be a positive integer, got ${contentConcurrency}`,
);
}
if (opts.lock) {
return runLockedBackup(lib, opts, downloadDirectory, opts.lock);
}
const lock = await lockBackupDirectory(downloadDirectory);
try {
return await runLockedBackup(lib, opts, downloadDirectory, lock);
} finally {
await lock.release();
}
};
+60 -98
View File
@@ -20,7 +20,6 @@ import {
type ClientSnapshot,
type LoginOptions,
} from "./client.js";
import { lockBackupDirectory, type BackupLock } from "./backup.js";
import { init } from "./crypto/index.js";
import {
defaultCacheDirectory,
@@ -74,9 +73,7 @@ export const saveSession = (
);
};
// 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.
// The saved client, or undefined after telling the user why there is none.
const requireSession = (ctx: CliContext): Client | undefined => {
let client: Client | null;
try {
@@ -97,20 +94,6 @@ const requireSession = (ctx: CliContext): Client | undefined => {
return client;
};
// `text` as an ID, or undefined after telling the user it is not one. An ID is
// written in decimal digits and is a safe integer; `Number()` alone would read
// `0x10` as 16, `1e3` as 1000 and "" as 0.
const parseID = (
ctx: CliContext,
kind: "file" | "collection",
text: string,
): number | undefined => {
const id = Number(text);
if (/^[0-9]+$/.test(text) && Number.isSafeInteger(id)) return id;
ctx.stderr.write(`Invalid ${kind} ID: ${text}\n`);
return undefined;
};
// A library client that omits `fetchMLData`, so the point commands below do not
// kick the library's background ML backfill: they read metadata, or fetch one
// file's content, and exit. `backup` and `backup-metadata` handle ML on their
@@ -167,7 +150,7 @@ export const loginCommand = async (ctx: CliContext): Promise<number> => {
export const whoamiCommand = async (ctx: CliContext): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 3;
if (!client) return 1;
const info = client.whoami();
ctx.stdout.write(JSON.stringify(info) + "\n");
return 0;
@@ -218,7 +201,7 @@ export const collectionsCommand = async (
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 3;
if (!client) return 1;
const lib = await openReadLibrary(ctx, client);
try {
// Force a server round-trip and list in enumeration order (issue #36
@@ -260,9 +243,12 @@ export const filesCommand = async (
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 3;
const collectionID = parseID(ctx, "collection", opts.collection);
if (collectionID === undefined) return 1;
if (!client) return 1;
const collectionID = Number(opts.collection);
if (!Number.isFinite(collectionID)) {
ctx.stderr.write("Invalid collection ID\n");
return 1;
}
const lib = await openReadLibrary(ctx, client);
try {
@@ -299,9 +285,12 @@ export const getCommand = async (
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 3;
const fileID = parseID(ctx, "file", fileIDStr);
if (fileID === undefined) return 1;
if (!client) return 1;
const fileID = Number(fileIDStr);
if (!Number.isFinite(fileID)) {
ctx.stderr.write("Invalid file ID\n");
return 1;
}
const lib = await openReadLibrary(ctx, client);
try {
@@ -354,9 +343,12 @@ export const getThumbCommand = async (
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 3;
const fileID = parseID(ctx, "file", fileIDStr);
if (fileID === undefined) return 1;
if (!client) return 1;
const fileID = Number(fileIDStr);
if (!Number.isFinite(fileID)) {
ctx.stderr.write("Invalid file ID\n");
return 1;
}
const lib = await openReadLibrary(ctx, client);
try {
@@ -388,7 +380,7 @@ export const backupMetadataCommand = async (
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 3;
if (!client) return 1;
const lib = await openReadLibrary(ctx, client);
try {
// Refresh first so the dump holds current account state, not what the
@@ -407,77 +399,51 @@ export const backupMetadataCommand = async (
export const backupCommand = async (
ctx: CliContext,
dir: string,
opts: { json?: boolean; verify?: boolean },
opts: { json?: boolean },
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 3;
if (!client) return 1;
ctx.stderr.write("Starting backup...\n");
// The lock is taken before the library opens and starts its refresh, so a
// run refused while another backup of `dir` runs sends no request.
let lock: BackupLock;
// The precache is off: the backup fetches what it needs, and must not
// also fill the cache with every thumbnail and the recent originals.
const lib = await Library.open({
client,
downloadDirectory: dir,
cacheDirectory: ctx.cacheDir,
precacheThumbnails: false,
precacheOriginals: false,
});
try {
lock = 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.
// No refresh starts on a timer: the backup lists its files once, from
// the refresh it waits for, and can run for hours.
const lib = await Library.open({
client,
const result = await lib.backup({
downloadDirectory: dir,
cacheDirectory: ctx.cacheDir,
refreshIntervalSeconds: Infinity,
precacheThumbnails: false,
precacheOriginals: false,
onProgress: (msg) => {
if (!opts.json) ctx.stderr.write(msg + "\n");
},
});
try {
const result = await lib.backup({
downloadDirectory: dir,
lock,
verify: opts.verify,
onProgress: (msg) => {
if (!opts.json) ctx.stderr.write(msg + "\n");
},
});
if (opts.json) {
ctx.stdout.write(JSON.stringify(result, null, 2) + "\n");
} else {
ctx.stderr.write("\n--- Backup complete ---\n");
ctx.stderr.write(` Total files: ${result.totalFiles}\n`);
ctx.stderr.write(` Downloaded: ${result.downloaded}\n`);
ctx.stderr.write(` Skipped: ${result.skipped}\n`);
if (opts.verify) {
ctx.stderr.write(` Verified: ${result.verified}\n`);
ctx.stderr.write(` Mismatched: ${result.mismatched}\n`);
ctx.stderr.write(` Unchecked: ${result.unchecked}\n`);
}
ctx.stderr.write(` Failed: ${result.failed}\n`);
if (result.errors.length > 0) {
ctx.stderr.write("\nFailed files:\n");
for (const e of result.errors) {
ctx.stderr.write(
` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`,
);
}
if (opts.json) {
ctx.stdout.write(JSON.stringify(result, null, 2) + "\n");
} else {
ctx.stderr.write("\n--- Backup complete ---\n");
ctx.stderr.write(` Total files: ${result.totalFiles}\n`);
ctx.stderr.write(` Downloaded: ${result.downloaded}\n`);
ctx.stderr.write(` Skipped: ${result.skipped}\n`);
ctx.stderr.write(` Failed: ${result.failed}\n`);
if (result.errors.length > 0) {
ctx.stderr.write("\nFailed files:\n");
for (const e of result.errors) {
ctx.stderr.write(
` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`,
);
}
}
return result.failed > 0 || result.undecryptableAlbums.length > 0
? 1
: 0;
} finally {
await lib.close();
}
return result.failed > 0 ? 1 : 0;
} finally {
await lock.release();
await lib.close();
}
};
@@ -487,7 +453,7 @@ export const listMissingThumbnailsCommand = async (
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 3;
if (!client) return 1;
const lib = await openReadLibrary(ctx, client);
try {
// Refresh first so files added since the cache was written are
@@ -525,20 +491,16 @@ export const fixMissingThumbnailsCommand = async (
): Promise<number> => {
await init();
const client = requireSession(ctx);
if (!client) return 3;
// One bad --file ID fails the whole command, before anything is fetched.
let fileIDs: number[] = [];
for (const text of opts.file ?? []) {
const fileID = parseID(ctx, "file", text);
if (fileID === undefined) return 1;
fileIDs.push(fileID);
}
if (!client) return 1;
const lib = await openReadLibrary(ctx, client);
try {
// Refresh first so files added since the cache was written are found;
// a failed refresh throws.
await lib.fresh();
if (fileIDs.length === 0) {
let fileIDs: number[];
if (opts.file && opts.file.length > 0) {
fileIDs = opts.file.map(Number).filter(Number.isFinite);
} else {
ctx.stderr.write("Scanning for missing thumbnails...\n");
const missing = await listMissingThumbnails(lib, client, (msg) => {
if (!opts.json) ctx.stderr.write(msg + "\n");
+5 -15
View File
@@ -1,15 +1,12 @@
// Runs one CLI command for `bin/quak.ts` and exits with its code.
import type { Writable } from "node:stream";
import { ApiError } from "./api/client.js";
// Run a command and exit with its code once stdout/stderr have drained.
// Exiting before the drain can truncate piped output, and the library can keep
// the event loop alive after a command returns, so a plain return could hang.
// An error the command throws is printed as one `quak: MESSAGE` line, without
// the stack trace, and exits 1. A 401 from the server means it no longer
// accepts the saved session: that prints one line saying to log in again and
// exits 3, as a missing or corrupt session file does.
// the stack trace, and exits 1.
export const run = async (
command: Promise<number>,
stdout: Writable,
@@ -20,17 +17,10 @@ export const run = async (
try {
code = await command;
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
stderr.write(
`quak: the saved session is no longer valid; run "quak login"\n`,
);
code = 3;
} else {
stderr.write(
`quak: ${err instanceof Error ? err.message : String(err)}\n`,
);
code = 1;
}
stderr.write(
`quak: ${err instanceof Error ? err.message : String(err)}\n`,
);
code = 1;
}
const pending = [stdout, stderr].filter((s) => s.writableLength > 0);
if (pending.length === 0) {
+26 -53
View File
@@ -44,28 +44,20 @@ export interface ClientSnapshot {
// The result of a resumable enumeration. Live decrypted records and deleted
// ids are kept apart on purpose: a tombstone carries no key or metadata to
// decrypt, so it is a bare id rather than a hollowed-out record. A live row
// that cannot be decrypted, or whose metadata cannot be parsed, is in
// `undecryptable` with the reason instead, so one bad row does not fail the
// call; absent means there was none. `cursor` is the max `updationTime` seen,
// those rows included, to pass back into the next call.
// decrypt, so it is a bare id rather than a hollowed-out record. `cursor` is
// the max `updationTime` seen, to pass back into the next call.
export interface CollectionsPage {
collections: Collection[];
deleted: number[];
undecryptable?: { id: number; error: string }[];
cursor: number;
}
export interface FilesPage {
files: EnteFile[];
deleted: number[];
undecryptable?: { id: number; error: string }[];
cursor: number;
}
const errorMessage = (err: unknown): string =>
err instanceof Error ? err.message : String(err);
export class Client {
private readonly api: ApiClient;
private readonly email: string;
@@ -245,12 +237,11 @@ export class Client {
}
// Enumerate collections changed since `sinceTime`. Live collections are
// decrypted; tombstoned ones (isDeleted) are surfaced as bare ids, and
// ones that do not decrypt as ids with the reason. The returned cursor is
// the max `updationTime` seen — including tombstones, so the next sync
// resumes past them — falling back to `sinceTime` when the response is
// empty. `/collections/v2` returns the whole changed set in one response,
// so there is no pagination here.
// decrypted; tombstoned ones (isDeleted) are surfaced as bare ids. The
// returned cursor is the max `updationTime` seen — including tombstones, so
// the next sync resumes past them — falling back to `sinceTime` when the
// response is empty. `/collections/v2` returns the whole changed set in one
// response, so there is no pagination here.
async collectionsSince(args: {
sinceTime: number;
}): Promise<CollectionsPage> {
@@ -263,40 +254,31 @@ export class Client {
const collections: Collection[] = [];
const deleted: number[] = [];
const undecryptable: { id: number; error: string }[] = [];
let cursor = args.sinceTime;
for (const raw of raws) {
if (raw.isDeleted) {
deleted.push(raw.id);
} else {
try {
collections.push(
decryptCollection(
raw,
{
masterKey: this.masterKey,
publicKey: this.publicKey,
secretKey: this.secretKey,
},
this.userID,
),
);
} catch (err) {
undecryptable.push({
id: raw.id,
error: errorMessage(err),
});
}
collections.push(
decryptCollection(
raw,
{
masterKey: this.masterKey,
publicKey: this.publicKey,
secretKey: this.secretKey,
},
this.userID,
),
);
}
if (raw.updationTime > cursor) cursor = raw.updationTime;
}
return { collections, deleted, undecryptable, cursor };
return { collections, deleted, cursor };
}
// Enumerate a collection's files changed since `sinceTime`, paginating the
// diff from that cursor. Live rows are decrypted; tombstoned ones are
// surfaced as bare ids, and ones that do not decrypt or parse as ids with
// the reason. Returns the final cursor to resume from.
// surfaced as bare ids. Returns the final cursor to resume from.
async filesSince(args: {
collectionID: number;
collectionKey: Uint8Array;
@@ -306,7 +288,6 @@ export class Client {
const { collectionID, collectionKey } = args;
const files: EnteFile[] = [];
const deleted: number[] = [];
const undecryptable: { id: number; error: string }[] = [];
let cursor = args.sinceTime;
for (;;) {
const { diff, hasMore } = await this.api.getJSON<{
@@ -319,14 +300,7 @@ export class Client {
if (raw.isDeleted) {
deleted.push(raw.id);
} else {
try {
files.push(decryptFile(raw, collectionKey));
} catch (err) {
undecryptable.push({
id: raw.id,
error: errorMessage(err),
});
}
files.push(decryptFile(raw, collectionKey));
}
if (raw.updationTime > pageMax) pageMax = raw.updationTime;
}
@@ -347,19 +321,18 @@ export class Client {
}
cursor = pageMax;
}
return { files, deleted, undecryptable, cursor };
return { files, deleted, cursor };
}
// Whole-account listing: every live collection that decrypts, deletions
// hidden. A thin wrapper over `collectionsSince` from the beginning of
// time.
// Whole-account listing: every live collection, deletions hidden. A thin
// wrapper over `collectionsSince` from the beginning of time.
async listCollections(): Promise<Collection[]> {
const { collections } = await this.collectionsSince({ sinceTime: 0 });
return collections;
}
// Every live file in a collection that decrypts, deletions hidden. A thin
// wrapper over `filesSince` from the beginning of time.
// Every live file in a collection, deletions hidden. A thin wrapper over
// `filesSince` from the beginning of time.
async listFiles(
collectionID: number,
collectionKey: Uint8Array,
+5 -3
View File
@@ -361,8 +361,8 @@ const openPart = async (
// written unpacked: each part is named `destination` with the extension
// replaced by its own entry's, and the two must differ ignoring case. When the
// file records a hash, `<imageHash>:<videoHash>` must match it, each over that
// part's own bytes. Only then are the image, then the video, renamed into
// place; on any failure neither is stored.
// part's own bytes. Only then is whatever was at `destination` removed and the
// image, then the video, renamed into place; on any failure neither is stored.
//
// The ZIP is chosen by its uploader and may expand enormously, so each part is
// written as it decompresses and never held, and the ZIP is refused once the
@@ -486,6 +486,7 @@ const decryptLivePhoto = async (
await part.handle.sync();
await part.handle.close();
}
await rm(destination, { force: true });
await rename(image.tmpPath, path);
try {
await rename(video.tmpPath, videoPath);
@@ -569,7 +570,8 @@ const fetchAndDecrypt = async (
}, api.getRetryOptions());
// Write `file`'s original to `outPath`. A live photo is written as its image
// and its video beside `outPath` instead (see `decryptLivePhoto`).
// and its video beside `outPath` instead, and whatever was at `outPath` is
// removed (see `decryptLivePhoto`).
export const downloadFile = async (
api: ApiClient,
file: EnteFile,
-164
View File
@@ -1,164 +0,0 @@
// EXIF in an original's bytes, read with exifreader, which reads it from JPEG,
// HEIC/HEIF, AVIF, PNG, WebP and the other image formats it supports.
// `backup-metadata --exif` records every EXIF tag it finds except the
// thumbnail's. `Photo.exif()` returns every tag, the thumbnail's included, and
// `Photo`'s typed methods return the common fields picked from them here.
import ExifReader, { type ExpandedTags } from "exifreader";
// The EXIF tags in `bytes` (`exif`), the embedded thumbnail's tags
// (`Thumbnail`), and where the EXIF block lies in `bytes` (`metadataRange`).
// Undefined when exifreader cannot read the file at all, such as a video. An
// EXIF block it finds but reads no tag from comes back as an empty `exif`. A
// tag exifreader has no name for is keyed `undefined-<tag number>`. Each tag's
// `computed` holds its value as a string or number, or as an array of them for
// a tag with several values, such as `GPSLatitude`'s `[40, 26, 46]`. A
// fraction with a zero denominator computes to null.
export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => {
try {
return ExifReader.loadView(
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength),
{
expanded: true,
computed: true,
includeOffsets: true,
includeUnknown: true,
includeTags: { exif: true, thumbnail: true },
},
);
} catch {
return undefined;
}
};
// Every EXIF tag of an original, keyed by name, each as exifreader decodes it
// (see `readExifTags`). The embedded thumbnail's own tags are under
// `Thumbnail`, so its `Orientation` or `ImageWidth` cannot hide the main
// image's.
export type ExifTags = Omit<NonNullable<ExpandedTags["exif"]>, "Thumbnail"> & {
Thumbnail?: Omit<
NonNullable<ExpandedTags["Thumbnail"]>,
"type" | "image" | "base64"
>;
};
// Every EXIF tag in `bytes`: `{}` when the file has no EXIF, exifreader cannot
// read its EXIF, or it is not an image exifreader reads.
export const readAllExifTags = (bytes: Uint8Array): ExifTags => {
const tags = readExifTags(bytes);
if (!tags?.Thumbnail) return tags?.exif ?? {};
// exifreader puts the thumbnail's JPEG image beside its tags, as `type`,
// `image` and `base64`. The image is not a tag, so it is left out.
const {
type: _type,
image: _image,
base64: _base64,
...thumbnail
} = tags.Thumbnail;
return { ...tags.exif, Thumbnail: thumbnail };
};
// The common EXIF fields of an original, one for each of `Photo`'s typed
// methods. Each is absent when the file lacks it.
export interface PhotoExif {
make?: string;
model?: string;
lensModel?: string;
// When the photo was taken, by the camera's clock. EXIF writes this as text
// with no time zone, and it is read as if it were UTC: the Date's UTC
// fields are the clock reading, which is the moment it was taken only when
// the clock was set to UTC.
dateTimeOriginal?: Date;
// The camera clock's offset from UTC, such as "+02:00".
offsetTimeOriginal?: string;
// Seconds.
exposureTime?: number;
fNumber?: number;
iso?: number;
// Millimetres.
focalLength?: number;
// The EXIF orientation code, 1 to 8.
orientation?: number;
// Decimal degrees, negative south of the equator and west of Greenwich.
gpsLatitude?: number;
gpsLongitude?: number;
// Metres, negative below sea level.
gpsAltitude?: number;
}
// exifreader gives "<faulty value>" for a tag whose value lies outside the
// file; that tag is left out like one the file lacks.
const asString = (v: unknown): string | undefined =>
typeof v === "string" && v.length > 0 && v !== "<faulty value>"
? v
: undefined;
const asNumber = (v: unknown): number | undefined =>
typeof v === "number" && Number.isFinite(v) ? v : undefined;
// EXIF writes a date and time as "2021:07:15 14:30:00". This is that reading
// in a Date's UTC fields.
const asDate = (v: unknown): Date | undefined => {
const m =
typeof v === "string"
? /^(\d{4}):(\d{2}):(\d{2}) (\d{2}:\d{2}:\d{2})$/.exec(v)
: null;
if (!m) return undefined;
const date = new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}Z`);
return Number.isNaN(date.getTime()) ? undefined : date;
};
// GPSLatitude and GPSLongitude hold degrees, minutes and seconds, computed as
// three numbers. This is them in decimal degrees, negative when `ref`, the
// GPSLatitudeRef or GPSLongitudeRef tag, is `negativeRef` ("S" or "W").
// Without that tag the hemisphere is unknown, so it is undefined.
const asDegrees = (
dms: unknown,
ref: unknown,
negativeRef: string,
): number | undefined => {
if (!Array.isArray(dms) || ref === undefined) return undefined;
const [d, m, s] = dms.map(asNumber);
if (d === undefined || m === undefined || s === undefined) return undefined;
const degrees = d + m / 60 + s / 3600;
return ref === negativeRef ? -degrees : degrees;
};
// The common fields picked from an original's EXIF tags, `readAllExifTags`'s
// result: `{}` when there are none.
export const readPhotoExif = (tags: ExifTags): PhotoExif => {
const altitude = asNumber(tags.GPSAltitude?.computed);
const fields: PhotoExif = {
make: asString(tags.Make?.computed),
model: asString(tags.Model?.computed),
lensModel: asString(tags.LensModel?.computed),
dateTimeOriginal: asDate(tags.DateTimeOriginal?.computed),
offsetTimeOriginal: asString(tags.OffsetTimeOriginal?.computed),
exposureTime: asNumber(tags.ExposureTime?.computed),
fNumber: asNumber(tags.FNumber?.computed),
// Only when the tag holds a single number, as most cameras write it.
iso: asNumber(tags.ISOSpeedRatings?.computed),
focalLength: asNumber(tags.FocalLength?.computed),
orientation: asNumber(tags.Orientation?.computed),
gpsLatitude: asDegrees(
tags.GPSLatitude?.computed,
tags.GPSLatitudeRef?.computed,
"S",
),
gpsLongitude: asDegrees(
tags.GPSLongitude?.computed,
tags.GPSLongitudeRef?.computed,
"W",
),
// A GPSAltitudeRef of 1 means the altitude is below sea level.
gpsAltitude:
altitude !== undefined && tags.GPSAltitudeRef?.value === 1
? -altitude
: altitude,
};
// Leave out what the file lacks, so a missing field is absent rather than
// present and undefined.
return Object.fromEntries(
Object.entries(fields).filter(([, v]) => v !== undefined),
) as PhotoExif;
};
+2 -9
View File
@@ -1,7 +1,5 @@
// A build reports the version script/build stamps into dist/package.json;
// package.json's own version is reported only when running from source. tsc
// copies package.json to dist/package.json, so this path resolves from source
// and from dist/src/.
// package.json is the one place the version is written. tsc copies it to
// dist/package.json, so this path resolves from source and from dist/src/.
import pkg from "../package.json" with { type: "json" };
export const VERSION: string = pkg.version;
@@ -27,7 +25,6 @@ export {
isRetryable,
isSafeToReplay,
resolveRetryOptions,
UNATTENDED_RETRY_OPTIONS,
withRetry,
type ResolvedRetryOptions,
type RetryOptions,
@@ -56,7 +53,6 @@ export {
type PhotoFilter,
type TimelineGroup,
type GroupBy,
type SavePathLookup,
type ContentSource,
type ContentResult,
type ContentEvent,
@@ -67,9 +63,7 @@ export {
type EnsureOptions,
type EnsureResult,
type EnsureEvent,
lockBackupDirectory,
runBackup,
type BackupLock,
type BackupOptions,
type BackupResult,
type BackupError,
@@ -90,7 +84,6 @@ export type {
LibrarySnapshot,
LibraryChange,
} from "./library/records.js";
export type { ExifTags, PhotoExif } from "./exif.js";
export { decryptCollection, decryptFile } from "./model/index.js";
export { downloadFile, downloadThumbnail } from "./download/index.js";
export type {
+79 -159
View File
@@ -23,19 +23,19 @@
// original with no recorded hash is stored unchecked, as the upstream client
// does; thumbnails have none. On top of that this module refuses to record a
// stored file that came out empty.
//
// It also names each original's save path under the download directory
// (`savePath`), where `Photo.download()` and `lib.backup()` put it. A copy in
// the cache does not count as saved there, but is copied there rather than
// fetched again.
import { existsSync, readFileSync, statSync } from "node:fs";
import {
closeSync,
existsSync,
openSync,
readFileSync,
readSync,
statSync,
} from "node:fs";
import {
chmod,
copyFile,
mkdir,
readdir,
rename,
rm,
stat,
statfs,
@@ -47,15 +47,13 @@ import type { ApiClient } from "../api/client.js";
import {
downloadFile,
downloadThumbnail,
fsyncPath,
type ProgressCallback,
removeLeftoverTempFiles,
writeAtomic,
} from "../download/index.js";
import { safeExtension, withExtension } from "../filename.js";
import { safeExtension } from "../filename.js";
import type { EnteFile } from "../model/types.js";
import type { Priority, RequestPools } from "./pools.js";
import { takenAtOf } from "./records.js";
const DIR_MODE = 0o700;
const FILE_MODE = 0o600;
@@ -110,9 +108,6 @@ export interface ContentOptions {
export interface PhotoContent {
original(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
thumbnail(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
// Put the original at the save path of `file`, the copy the `Photo` holds,
// and return it there.
download(file: EnteFile): Promise<ContentResult>;
}
export interface EnsureResult {
@@ -199,10 +194,10 @@ export interface ContentCacheOptions {
pools: RequestPools;
source: ContentSource;
cacheDirectory: string;
// The root of the save paths. An original already stored at its save path
// counts as present, so the cache serves it rather than fetching a second
// copy.
downloadDirectory: string;
// The backup destination (issue-level `downloadDirectory`). An original
// already stored there by a backup counts as present, so the cache serves
// it rather than fetching a second copy.
downloadDirectory?: string;
// Resolve any membership of a file; every membership shares the underlying
// content key, so any one decrypts the same bytes.
getFile: (fileID: number) => EnteFile | undefined;
@@ -229,27 +224,11 @@ class AbortDrop extends Error {
}
}
// The name of a file's original in the cache's originals/: `<fileID><ext>`, the
// extension taken from the title (or `.bin`).
// The name of a file's original in originals/: `<fileID><ext>`, the extension
// taken from the title (or `.bin`). A backup names its originals the same way.
export const nameInOriginals = (file: EnteFile): string =>
`${file.id}${safeExtension(file.metadata.title)}`;
const pad = (n: number): string => String(n).padStart(2, "0");
// Where the original of `file` is saved under `root`:
// `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID><ext>`, dated by the photo's
// `takenAt` in this machine's time zone, with the extension taken from the
// title (or `.bin`). A live photo is stored as its image and its video beside
// this path, each with the extension found inside the live photo.
export const savePath = (root: string, file: EnteFile): string => {
const taken = new Date(takenAtOf(file));
const year = String(taken.getFullYear());
const month = `${year}-${pad(taken.getMonth() + 1)}`;
const day = `${month}-${pad(taken.getDate())}`;
const ext = safeExtension(file.metadata.title);
return join(root, year, month, day, `${day}.${file.id}${ext}`);
};
// The fileID a cache filename encodes, or undefined when the name is not one
// the cache writes (`<digits><ext>`).
const fileIDFromName = (name: string): number | undefined => {
@@ -274,31 +253,49 @@ const fileSize = (path: string): number | undefined => {
const hasContent = (path: string | undefined): boolean =>
path !== undefined && (fileSize(path) ?? 0) > 0;
// Whether the file at `path` begins as a ZIP does, with `PK\x03\x04`. False
// when it cannot be read.
const isZip = (path: string): boolean => {
try {
const fd = openSync(path, "r");
try {
const head = Buffer.alloc(4);
return (
readSync(fd, head, 0, 4, 0) === 4 &&
head.toString("latin1") === "PK\x03\x04"
);
} finally {
closeSync(fd);
}
} catch {
return false;
}
};
// A live photo's image and video are named with the extensions from inside its
// ZIP, so their names alone do not say which is which. Wherever the cache or a
// save path stores one, a JSON file of this name beside them names both.
// `name` is the original's name without its extension: `<fileID>` in the
// cache, `YYYY-MM-DD.<fileID>` at the save path.
const livePhotoJSONName = (name: string): string => `${name}.livephoto.json`;
// backup stores one, a JSON file of this name beside them names both.
const livePhotoJSONName = (fileID: number): string =>
`${fileID}.livephoto.json`;
// The image and video that the live photo's JSON file in `dir` names, or
// undefined when there is none. Only `name` with an extension is taken, so the
// file cannot point outside `dir`.
// undefined when there is none. Only names of the form the cache writes are
// taken, so the file cannot point outside `dir`.
const readLivePhotoJSON = (
dir: string,
name: string,
fileID: number,
): { path: string; videoPath: string } | undefined => {
const valid = (part: unknown): part is string =>
typeof part === "string" && part === `${name}${safeExtension(part)}`;
const valid = (name: unknown): name is string =>
typeof name === "string" && name === `${fileID}${safeExtension(name)}`;
try {
const { image, video } = JSON.parse(
readFileSync(join(dir, livePhotoJSONName(name)), "utf-8"),
readFileSync(join(dir, livePhotoJSONName(fileID)), "utf-8"),
);
if (valid(image) && valid(video)) {
return { path: join(dir, image), videoPath: join(dir, video) };
}
} catch {
// No such file, or not one quak wrote.
// No such file, or not one the cache wrote.
}
return undefined;
};
@@ -306,11 +303,11 @@ const readLivePhotoJSON = (
// Write the JSON file naming a live photo's image and video, both in `dir`.
export const writeLivePhotoJSON = (
dir: string,
name: string,
fileID: number,
stored: { path: string; videoPath: string },
): Promise<void> =>
writeAtomic(
join(dir, livePhotoJSONName(name)),
join(dir, livePhotoJSONName(fileID)),
new TextEncoder().encode(
JSON.stringify({
image: basename(stored.path),
@@ -319,19 +316,17 @@ export const writeLivePhotoJSON = (
),
);
// The original of `file` as stored in `dir` under `name` (without its
// extension), when all of it is there: `<name><ext>`, or a live photo's image
// and video.
// The original of `file` as the cache or a backup stored it in `dir`, when all
// of it is there: `<fileID><ext>`, or a live photo's image and video.
export const storedOriginal = (
dir: string,
name: string,
file: EnteFile,
): { path: string; videoPath?: string } | undefined => {
if (file.metadata.fileType !== "livePhoto") {
const path = join(dir, `${name}${safeExtension(file.metadata.title)}`);
const path = join(dir, nameInOriginals(file));
return hasContent(path) ? { path } : undefined;
}
const stored = readLivePhotoJSON(dir, name);
const stored = readLivePhotoJSON(dir, file.id);
return stored !== undefined &&
hasContent(stored.path) &&
hasContent(stored.videoPath)
@@ -339,76 +334,10 @@ export const storedOriginal = (
: undefined;
};
// The original of `file` as stored at its save path under `root`, when all of
// it is there.
export const storedAtSavePath = (
root: string,
file: EnteFile,
): { path: string; videoPath?: string } | undefined => {
const path = savePath(root, file);
return storedOriginal(dirname(path), basename(path, extname(path)), file);
};
// Copy bytes into `dest` via a temp file in the same directory plus rename, so
// `dest` appears only once it is whole ("present means complete"). As in the
// download writer, the temp file is fsynced before the rename and the directory
// after it, so a power cut cannot leave a correctly named but short original.
// The temp name carries this process's ID so a later run can tell a leftover
// from a copy still in progress (see `removeLeftoverTempFiles`).
export const copyAtomic = async (src: string, dest: string): Promise<void> => {
if (src === dest) return;
const tmp = join(
dirname(dest),
`.quak-backup-${basename(dest)}-${process.pid}-${Math.random()
.toString(36)
.slice(2)}.tmp`,
);
try {
await copyFile(src, tmp);
await fsyncPath(tmp);
// `rename` replaces the destination's directory entry: an existing
// symlink at `dest` is replaced, not followed, and the new file has
// the temp file's permissions (copied from `src`).
await rename(tmp, dest);
await fsyncPath(dirname(dest));
} finally {
await rm(tmp, { force: true });
}
};
// Put the original of `file` at its save path under `root`, creating its
// folders. `get` is given the save path and returns where the original is: a
// fetch writes it there, and a copy the cache holds is copied there. A live
// photo's image and video go beside the save path, each with its own
// extension: when they came from the cache they are copied. Then the JSON file
// naming them is written, which is what makes the live photo count as stored.
export const placeOriginal = async (
root: string,
file: EnteFile,
get: (dest: string) => Promise<{ path: string; videoPath?: string }>,
): Promise<{ path: string; videoPath?: string }> => {
const dest = savePath(root, file);
await mkdir(dirname(dest), { recursive: true });
const got = await get(dest);
if (got.videoPath === undefined) {
await copyAtomic(got.path, dest);
return { path: dest };
}
const path = withExtension(dest, extname(got.path));
const videoPath = withExtension(dest, extname(got.videoPath));
if (got.path !== path) {
await copyAtomic(got.path, path);
await copyAtomic(got.videoPath, videoPath);
}
const name = basename(dest, extname(dest));
await writeLivePhotoJSON(dirname(dest), name, { path, videoPath });
return { path, videoPath };
};
export class ContentCache implements PhotoContent, ThumbnailsAPI {
private readonly pools: RequestPools;
private readonly source: ContentSource;
private readonly downloadDirectory: string;
private readonly downloadDirectory?: string;
private readonly getFile: (fileID: number) => EnteFile | undefined;
private readonly originalsDir: string;
private readonly thumbnailsDir: string;
@@ -506,23 +435,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress);
}
// Put the original at the save path of `file` under the download directory
// and return it there. `file` is the copy the `Photo` holds, so the path is
// the one its `savePath` names, even after a refresh changed the date. One
// already stored there is returned as it is; one the cache holds is copied
// from it; any other is fetched straight to the save path, with no copy
// left in the cache.
async download(file: EnteFile): Promise<ContentResult> {
const root = this.downloadDirectory;
const saved =
storedAtSavePath(root, file) ??
(await placeOriginal(root, file, (dest) =>
this.backupOriginal(file.id, dest),
));
return { ...saved, bytes: fileSize(saved.path) ?? 0 };
}
// Get an original for a save path. One not present anywhere is written
// Get an original for a backup. One not present anywhere is written
// straight to `destination` and recorded there, so no second copy lands
// in the cache; one already present is returned where it is.
async backupOriginal(
@@ -687,16 +600,17 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
await this.touch(cached.path);
return { ...cached, bytes: size, cached: true };
}
// A recorded file that has since gone re-fetches below. So does a
// live photo recorded with no video: the cache opened before the
// library's records said it is a live photo, while its image and
// video had no JSON file beside them yet.
// A recorded file that has since gone, or a live photo an earlier
// version stored as one ZIP, re-fetches below.
known.delete(fileID);
}
// An original already stored at its save path counts as present.
if (kind === "original") {
const stored = storedAtSavePath(this.downloadDirectory, file);
// An original a backup already stored counts as present.
if (kind === "original" && this.downloadDirectory !== undefined) {
const stored = storedOriginal(
join(this.downloadDirectory, "originals"),
file,
);
if (stored !== undefined) {
this.originals.set(fileID, stored);
return {
@@ -732,7 +646,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
? this.beginOriginalWrite(fileID)
: null;
try {
const stored = await this.fetchInto(
const stored = await this.download(
file,
dest,
kind,
@@ -747,12 +661,12 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
);
}
}
// `placeOriginal` records a live photo it saves.
// A backup records its own live photos.
if (
stored.videoPath !== undefined &&
opts?.destination === undefined
) {
await writeLivePhotoJSON(dir, String(fileID), {
await writeLivePhotoJSON(dir, fileID, {
path: stored.path,
videoPath: stored.videoPath,
});
@@ -775,7 +689,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
// Fetch into `destination`, returning where the bytes landed: there, or
// for a live photo, its image and video beside it.
private async fetchInto(
private async download(
file: EnteFile,
destination: string,
kind: Kind,
@@ -800,10 +714,10 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
await utimes(path, now, now).catch(() => undefined);
}
// Every stored original that lives under `originalsDir` (a save-path hit
// recorded in the map is excluded), with its size and mtime; a live
// Every stored original that lives under `originalsDir` (a backup-directory
// hit recorded in the map is excluded), with its size and mtime; a live
// photo's size includes its video. Entries whose file has vanished are
// dropped from the map. Save paths and thumbnails are never counted.
// dropped from the map. Backups and thumbnails are never counted.
private async measureOriginals(): Promise<{
entries: {
fileID: number;
@@ -909,7 +823,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
await rm(
join(
this.originalsDir,
livePhotoJSONName(String(e.fileID)),
livePhotoJSONName(e.fileID),
),
{ force: true },
);
@@ -959,14 +873,20 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
if (id === undefined || !existsSync(path)) continue;
// A live photo's image and video are one entry, as the JSON file
// beside them names them. A live photo's file with no such JSON
// file is not its original and is left alone: another process may
// have just stored it and not yet written the JSON file.
const livePhoto = names.has(livePhotoJSONName(String(id)))
? readLivePhotoJSON(dir, String(id))
// file is not its original. If it is a ZIP, it is the one an
// earlier version stored under the image's name, and is removed.
// Any other is left alone: another process may have just stored
// it and not yet written the JSON file.
const livePhoto = names.has(livePhotoJSONName(id))
? readLivePhotoJSON(dir, id)
: undefined;
if (livePhoto !== undefined) {
into.set(id, livePhoto);
} else if (!isLivePhoto(id)) {
} else if (isLivePhoto(id)) {
if (isZip(path)) {
await rm(path, { force: true }).catch(() => undefined);
}
} else {
into.set(id, { path });
}
}
+38 -102
View File
@@ -27,7 +27,7 @@
// never masked by a subsequent empty refresh.
import { rm } from "node:fs/promises";
import { join, resolve } from "node:path";
import { join } from "node:path";
import envPaths from "env-paths";
import { MetadataStore } from "./store.js";
@@ -49,12 +49,9 @@ import {
type PhotosAPI,
type TimelineAPI,
type FreshReads,
type SavePathLookup,
} from "./read.js";
import {
ContentCache,
savePath,
storedAtSavePath,
type ContentSource,
type ThumbnailsAPI,
type EnsureOptions,
@@ -73,7 +70,6 @@ export {
type PhotoFilter,
type TimelineGroup,
type GroupBy,
type SavePathLookup,
} from "./read.js";
export {
type ContentSource,
@@ -94,9 +90,7 @@ import type { Collection, EnteFile } from "../model/types.js";
import { runBackup, type BackupOptions, type BackupResult } from "../backup.js";
export {
lockBackupDirectory,
runBackup,
type BackupLock,
type BackupOptions,
type BackupResult,
type BackupError,
@@ -175,14 +169,9 @@ export interface LibraryOptions {
// Where `metadata.json` lives. Defaults to the env-paths cache directory
// plus the user id, so each account has its own cache.
cacheDirectory?: string;
// The root of every photo's save path, where `Photo.download()` and
// `lib.backup()` put originals. Defaults to `photos` in the working
// directory at open. The content cache treats an original already stored
// at its save path as present.
// Persistent backup destination. The refresh loop does not use it; the
// content cache treats an original already stored there as present.
downloadDirectory?: string;
// How long after one refresh ends the background timer starts the next.
// `Infinity` starts none: the library then refreshes only as it opens and
// when `fresh()` or `backup()` asks.
refreshIntervalSeconds?: number;
onProgress?: RefreshProgressCallback;
// The bounded request pools (issue #45), shared by the ML-data fetch (the
@@ -245,7 +234,7 @@ export interface LibraryStatus {
export class Library {
readonly cacheDirectory: string;
readonly downloadDirectory: string;
readonly downloadDirectory?: string;
// The in-process read surface (issue #44). Each namespace answers
// synchronously from the live record projection; no read touches the
@@ -284,8 +273,7 @@ export class Library {
private cycle?: Promise<void>;
// Guards the ML fetch pass so a slow backfill never runs twice at once; a
// refresh whose pass is still running kicks nothing new. Holds the running
// pass, so `close()` and `backup()` can wait for it. It rejects when the
// pass fails.
// pass, so `close()` can wait for it.
private mlFetch?: Promise<void>;
private closed = false;
private lastRefreshAt?: number;
@@ -308,7 +296,7 @@ export class Library {
store: MetadataStore;
userID: number;
cacheDirectory: string;
downloadDirectory: string;
downloadDirectory?: string;
intervalMs: number;
onProgress?: RefreshProgressCallback;
pools: RequestPools;
@@ -332,14 +320,8 @@ export class Library {
// The read namespaces derive fresh from the store on each call, so they
// always reflect the latest refresh.
const derive = (): DerivedRecords => this.deriveNow();
const root = this.downloadDirectory;
const saves: SavePathLookup = {
savePath: (file) =>
storedAtSavePath(root, file)?.path ?? savePath(root, file),
isLocal: (file) => storedAtSavePath(root, file) !== undefined,
};
this.albums = makeAlbumsAPI(derive, saves, this.cache);
this.photos = makePhotosAPI(derive, saves, this.cache);
this.albums = makeAlbumsAPI(derive, this.cache);
this.photos = makePhotosAPI(derive, this.cache);
this.timeline = makeTimelineAPI(derive);
this.thumbnails = {
ensure: (opts: EnsureOptions): Promise<EnsureResult[]> => {
@@ -368,13 +350,6 @@ export class Library {
const { userID } = opts.client.whoami();
const cacheDirectory =
opts.cacheDirectory ?? defaultCacheDirectory(userID);
if (opts.downloadDirectory === "") {
throw new Error(
"library: downloadDirectory is empty (leave it out to save " +
"under photos/ in the working directory)",
);
}
const downloadDirectory = opts.downloadDirectory ?? resolve("photos");
const metadataPath = join(cacheDirectory, "metadata.json");
let store = await MetadataStore.load(metadataPath);
// A cache directory given explicitly can hold another account's cache.
@@ -429,7 +404,7 @@ export class Library {
pools,
source,
cacheDirectory,
downloadDirectory,
downloadDirectory: opts.downloadDirectory,
getFile: (fileID) => store.getFileByID(fileID),
cacheOriginalsMaxBytes: opts.cacheOriginalsMaxBytes,
freeBelowBytes: opts.freeBelowBytes,
@@ -451,7 +426,7 @@ export class Library {
store,
userID,
cacheDirectory,
downloadDirectory,
downloadDirectory: opts.downloadDirectory,
intervalMs,
onProgress: opts.onProgress,
pools,
@@ -495,10 +470,9 @@ export class Library {
return this.store.getFile(collectionID, fileID);
}
// The membership of a file its record is read from, addressed by file id
// alone. A file's own metadata (title, creationTime) is identical across
// the collections it belongs to, so this serves the point commands that
// hold only a fileID.
// Any membership of a file, addressed by file id alone. A file's own
// metadata (title, creationTime) is identical across the collections it
// belongs to, so this serves the point commands that hold only a fileID.
getFileByID(fileID: number): EnteFile | undefined {
return this.store.getFileByID(fileID);
}
@@ -569,26 +543,27 @@ export class Library {
};
}
// Back up every in-scope file to `opts.downloadDirectory`, or else the
// library's, each original at its save path, with a durable failure
// ledger (issue #51). Takes the lock in that directory first, unless the
// caller passes the lock it took as `opts.lock`, failing at once while
// another backup of it runs, and before it starts another file once the
// lock is lost (see `lockBackupDirectory`). Waits for a
// completed refresh, as `fresh()` does, joining one already running, and
// rejects before touching any file but the lock when it fails. Then puts
// pending originals at their save paths as `Photo.download()` does (and
// 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.
// Back up every in-scope file to `downloadDirectory` in the historical
// on-disk layout, with a durable failure ledger (issue #51). Waits for a
// completed refresh first, as `fresh()` does, joining one already running,
// and rejects before touching any file when it fails. Then fetches pending
// originals (and optional thumbnails) through the content cache and pools,
// and rebuilds the derived symlink/JSON views from the model. Throws before
// any network work when no download directory is available or no content
// cache backs the originals it must fetch.
backup(opts?: BackupOptions): Promise<BackupResult> {
const downloadDirectory =
opts?.downloadDirectory ?? this.downloadDirectory;
const includeOriginals = opts?.includeOriginals ?? true;
const includeThumbnails = opts?.includeThumbnails ?? false;
if (!downloadDirectory) {
return Promise.reject(
new Error(
"backup requires a downloadDirectory (pass one to " +
"backup() or open the library with one)",
),
);
}
if ((includeOriginals || includeThumbnails) && !this.cache) {
return Promise.reject(
new Error(
@@ -599,17 +574,12 @@ export class Library {
const cache = this.cache;
return runBackup(
{
whoami: () => this.client.whoami(),
refresh: () => this.refreshNow(),
listCollections: () => this.store.listCollections(),
listFiles: (id) => this.store.listFiles(id),
undecryptable: () => this.store.listUndecryptable(),
original: (fileID, destination) =>
cache!.backupOriginal(fileID, destination),
thumbnail: (fileID) => cache!.thumbnail(fileID),
contentConcurrency: this.pools.content.concurrency,
fetchMLData: () => this.fetchMLDataNow(),
mlData: (fileID) => this.mldata.forFile({ fileID }),
},
{ ...opts, downloadDirectory },
);
@@ -629,13 +599,12 @@ export class Library {
this.timer = undefined;
}
await this.cycle?.catch(() => {});
await this.mlFetch?.catch(() => {});
await this.mlFetch;
await precacheClosed;
}
private scheduleNext(): void {
// `setTimeout` would turn an infinite delay into 1 ms.
if (this.closed || this.intervalMs === Infinity) return;
if (this.closed) return;
this.timer = setTimeout(() => {
void this.runRefresh().then(() => this.scheduleNext());
}, this.intervalMs);
@@ -694,9 +663,10 @@ export class Library {
// Backfill ML data for the files this refresh knows about. It runs
// outside the refresh's success/failure so a fetch or disk problem
// there never marks the metadata refresh failed, and it is not
// awaited so it never stalls the refresh interval. Its failure is
// reported through `status()` and `onProgress`.
void this.fetchMLDataNow().catch(() => {});
// awaited so it never stalls the refresh interval.
this.mlFetch ??= this.runMLFetch().finally(() => {
this.mlFetch = undefined;
});
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
this.lastError = error;
@@ -707,10 +677,7 @@ export class Library {
// Fetch every change since the stored cursor, then commit. All network
// reads happen before any store mutation, so a fetch that throws leaves the
// store untouched and the previous snapshot intact. 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.
// store untouched and the previous snapshot intact.
private async refreshOnce(): Promise<void> {
const page = await this.client.collectionsSince({
sinceTime: this.store.collectionsSinceTime,
@@ -747,17 +714,9 @@ export class Library {
this.store.deleteCollection(id);
changed = true;
}
if (this.store.deleteUndecryptable(id)) changed = true;
}
// A collection left out takes its files with it.
for (const { id, error } of page.undecryptable ?? []) {
this.store.deleteCollection(id);
this.store.putUndecryptable({ collectionID: id, error });
changed = true;
}
for (const collection of page.collections) {
this.store.putCollection(collection);
this.store.deleteUndecryptable(collection.id);
changed = true;
}
@@ -767,22 +726,9 @@ export class Library {
this.store.deleteFile(collectionID, id);
changed = true;
}
if (this.store.deleteUndecryptable(collectionID, id)) {
changed = true;
}
}
for (const { id, error } of filePage.undecryptable ?? []) {
this.store.deleteFile(collectionID, id);
this.store.putUndecryptable({
collectionID,
fileID: id,
error,
});
changed = true;
}
for (const f of filePage.files) {
this.store.putFile(f);
this.store.deleteUndecryptable(collectionID, f.id);
changed = true;
}
}
@@ -825,19 +771,10 @@ export class Library {
}
}
// Join the running ML fetch pass, or start one when none runs. Resolves at
// once when the client cannot fetch ML data; rejects when the pass fails.
private fetchMLDataNow(): Promise<void> {
this.mlFetch ??= this.runMLFetch().finally(() => {
this.mlFetch = undefined;
});
return this.mlFetch;
}
// One ML fetch pass: fetch, decrypt and store the ML data for every file
// the store knows about that is not cached (or whose `updationTime` has
// advanced), through the metadata pool, and update the CLIP index. A
// failure is reported through `status()` and `onProgress`, then thrown.
// advanced), through the metadata pool, and update the CLIP index. Guarded
// so passes never overlap; a failure is reported, not thrown.
private async runMLFetch(): Promise<void> {
const mldata = this.mlStore;
// Bind so the call keeps the client as its receiver when invoked
@@ -881,7 +818,6 @@ export class Library {
const error = err instanceof Error ? err.message : String(err);
this.lastMLError = error;
this.emit({ operation: "fetchMLData", status: "failed", error });
throw err;
}
}
+21 -169
View File
@@ -11,32 +11,15 @@
// access and, for an album, its photos. They are not sent across IPC — the
// plain records are the serializable surface, and `record()` returns one.
//
// A `Photo` also fetches its own bytes: `original()`, `thumbnail()`,
// `download()`, `content()`, `exif()` and the methods that each return one
// EXIF field go through the on-disk content cache (issue #46), and are
// the one place in this module that may touch the network. A library opened
// without a content source leaves that cache absent, and those methods then
// throw. `savePath` and `isLocal` look only at the disk and need no cache.
// A `Photo` also fetches its own bytes: `original()` and `thumbnail()` go
// through the on-disk content cache (issue #46), the one place in this module
// that is not synchronous and RAM-only. A library opened without a content
// source leaves that cache absent, and those two methods then throw.
import { readFile } from "node:fs/promises";
import {
readAllExifTags,
readPhotoExif,
type ExifTags,
type PhotoExif,
} from "../exif.js";
import type { CollectionType, EnteFile, FileType } from "../model/types.js";
import type { CollectionType, FileType } from "../model/types.js";
import type { ContentOptions, ContentResult, PhotoContent } from "./content.js";
import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js";
// Where a photo's original is saved, and whether all of it is there. The
// library answers both from the disk, with or without a content cache.
export interface SavePathLookup {
savePath(file: EnteFile): string;
isLocal(file: EnteFile): boolean;
}
// Newest first, with fileID as a stable tiebreak so equal-timed files order
// deterministically — the same order the record projection uses.
const byNewest = (a: PhotoRecord, b: PhotoRecord): number =>
@@ -47,23 +30,12 @@ const byNewest = (a: PhotoRecord, b: PhotoRecord): number =>
const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number =>
b.updationTime - a.updationTime || b.collectionID - a.collectionID;
// A method for each `PhotoExif` field, named after it, taking the options
// `exif()` takes and giving that field. `Photo` implements it, so the build's
// type check fails when `PhotoExif` has a field `Photo` has no method for.
type PhotoExifMethods = {
[K in keyof PhotoExif]-?: (opts?: ContentOptions) => Promise<PhotoExif[K]>;
};
// A single photo. Field access mirrors `PhotoRecord`; `record()` returns the
// underlying plain record for callers that need the IPC-safe value. `file` is
// the membership the record is read from, so the save path carries the date of
// `takenAt` and stays known after a refresh removes the file from the library.
export class Photo implements PhotoExifMethods {
// underlying plain record for callers that need the IPC-safe value.
export class Photo {
constructor(
private readonly rec: PhotoRecord,
private readonly file: EnteFile,
private readonly saves: SavePathLookup,
private readonly cache?: PhotoContent,
private readonly content?: PhotoContent,
) {}
get fileID(): number {
@@ -78,13 +50,6 @@ export class Photo implements PhotoExifMethods {
get takenAt(): number {
return this.rec.takenAt;
}
get modifiedAt(): number {
return this.rec.modifiedAt;
}
// The local-time year of `takenAt`.
get year(): number {
return new Date(this.rec.takenAt).getFullYear();
}
get fileType(): FileType {
return this.rec.fileType;
}
@@ -103,9 +68,6 @@ export class Photo implements PhotoExifMethods {
get longitude(): number | undefined {
return this.rec.longitude;
}
get hash(): string | undefined {
return this.rec.hash;
}
get isArchived(): boolean {
return this.rec.isArchived;
}
@@ -113,132 +75,30 @@ export class Photo implements PhotoExifMethods {
return this.rec.isHidden;
}
// Where `download()` and `lib.backup()` put the original under the
// library's download directory, whether or not it is there yet:
// `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID><ext>`. For a live photo
// already stored, its image. For a live photo not yet stored, it carries
// the title's extension, and the image may be stored under a different
// one.
get savePath(): string {
return this.saves.savePath(this.file);
}
// Whether the whole original is at `savePath`. A copy only in the cache
// does not count.
get isLocal(): boolean {
return this.saves.isLocal(this.file);
}
record(): PhotoRecord {
return this.rec;
}
// Fetch and cache the full-resolution original, returning its on-disk path
// and byte length; for a live photo, its image's, and its video's path as
// `videoPath`. Served from the cache (or the save path) when already
// present, otherwise fetched through the content pool.
// `videoPath`. Served from the cache (or the backup download directory)
// when already present, otherwise fetched through the content pool.
async original(opts?: ContentOptions): Promise<ContentResult> {
return this.cacheOrThrow().original(this.rec.fileID, opts);
}
// Put the original at `savePath` and return it there, as `original()`
// does. When it is already there, nothing is written. When the cache holds
// it, it is copied from there; otherwise it is fetched straight to
// `savePath`.
async download(): Promise<ContentResult> {
return this.cacheOrThrow().download(this.file);
return this.contentOrThrow().original(this.rec.fileID, opts);
}
// As `original`, for the thumbnail, through the thumbnail pool.
async thumbnail(opts?: ContentOptions): Promise<ContentResult> {
return this.cacheOrThrow().thumbnail(this.rec.fileID, opts);
return this.contentOrThrow().thumbnail(this.rec.fileID, opts);
}
// The original's bytes, read from where `original()` puts it. For a live
// photo, its image's.
async content(opts?: ContentOptions): Promise<Uint8Array> {
const { path } = await this.original(opts);
return readFile(path);
}
// Every EXIF tag of the original, keyed by name (see `ExifTags`), read
// from `content()`, so this may download it. EXIF is read from any image
// format exifreader reads, JPEG and HEIC/HEIF among them; any other file
// gives `{}`, and a video gives it without fetching anything. Like the
// other content methods, it throws when there is no content cache, video
// or not.
async exif(opts?: ContentOptions): Promise<ExifTags> {
this.cacheOrThrow();
if (this.rec.fileType === "video") return {};
return readAllExifTags(await this.content(opts));
}
// One field each, named and typed as in `PhotoExif`, picked from the tags
// `exif()` returns, and undefined when the file lacks it. Each call runs
// `exif()`, which reads the original again.
async make(opts?: ContentOptions): Promise<PhotoExif["make"]> {
return readPhotoExif(await this.exif(opts)).make;
}
async model(opts?: ContentOptions): Promise<PhotoExif["model"]> {
return readPhotoExif(await this.exif(opts)).model;
}
async lensModel(opts?: ContentOptions): Promise<PhotoExif["lensModel"]> {
return readPhotoExif(await this.exif(opts)).lensModel;
}
async dateTimeOriginal(
opts?: ContentOptions,
): Promise<PhotoExif["dateTimeOriginal"]> {
return readPhotoExif(await this.exif(opts)).dateTimeOriginal;
}
async offsetTimeOriginal(
opts?: ContentOptions,
): Promise<PhotoExif["offsetTimeOriginal"]> {
return readPhotoExif(await this.exif(opts)).offsetTimeOriginal;
}
async exposureTime(
opts?: ContentOptions,
): Promise<PhotoExif["exposureTime"]> {
return readPhotoExif(await this.exif(opts)).exposureTime;
}
async fNumber(opts?: ContentOptions): Promise<PhotoExif["fNumber"]> {
return readPhotoExif(await this.exif(opts)).fNumber;
}
async iso(opts?: ContentOptions): Promise<PhotoExif["iso"]> {
return readPhotoExif(await this.exif(opts)).iso;
}
async focalLength(
opts?: ContentOptions,
): Promise<PhotoExif["focalLength"]> {
return readPhotoExif(await this.exif(opts)).focalLength;
}
async orientation(
opts?: ContentOptions,
): Promise<PhotoExif["orientation"]> {
return readPhotoExif(await this.exif(opts)).orientation;
}
async gpsLatitude(
opts?: ContentOptions,
): Promise<PhotoExif["gpsLatitude"]> {
return readPhotoExif(await this.exif(opts)).gpsLatitude;
}
async gpsLongitude(
opts?: ContentOptions,
): Promise<PhotoExif["gpsLongitude"]> {
return readPhotoExif(await this.exif(opts)).gpsLongitude;
}
async gpsAltitude(
opts?: ContentOptions,
): Promise<PhotoExif["gpsAltitude"]> {
return readPhotoExif(await this.exif(opts)).gpsAltitude;
}
private cacheOrThrow(): PhotoContent {
if (!this.cache) {
private contentOrThrow(): PhotoContent {
if (!this.content) {
throw new Error(
"Photo content requires a library opened with a content cache",
);
}
return this.cache;
return this.content;
}
}
@@ -248,7 +108,6 @@ export class Album {
constructor(
private readonly rec: AlbumRecord,
private readonly records: DerivedRecords,
private readonly saves: SavePathLookup,
private readonly content?: PhotoContent,
) {}
@@ -283,10 +142,7 @@ export class Album {
const out: Photo[] = [];
for (const id of this.rec.fileIDs) {
const p = this.records.photos.get(id);
const file = this.records.files.get(id);
if (p && file) {
out.push(new Photo(p, file, this.saves, this.content));
}
if (p) out.push(new Photo(p, this.content));
}
return out;
}
@@ -349,19 +205,18 @@ export interface FreshReads {
export const makeAlbumsAPI = (
derive: () => DerivedRecords,
saves: SavePathLookup,
content?: PhotoContent,
): AlbumsAPI => ({
list: (): Album[] => {
const records = derive();
return [...records.albums.values()]
.sort(byNewestAlbum)
.map((rec) => new Album(rec, records, saves, content));
.map((rec) => new Album(rec, records, content));
},
byID: ({ collectionID }): Album | undefined => {
const records = derive();
const rec = records.albums.get(collectionID);
return rec ? new Album(rec, records, saves, content) : undefined;
return rec ? new Album(rec, records, content) : undefined;
},
byName: ({ albumName }): Album | undefined => {
const records = derive();
@@ -370,20 +225,17 @@ export const makeAlbumsAPI = (
const match = [...records.albums.values()]
.sort(byNewestAlbum)
.find((rec) => rec.name === albumName);
return match ? new Album(match, records, saves, content) : undefined;
return match ? new Album(match, records, content) : undefined;
},
});
export const makePhotosAPI = (
derive: () => DerivedRecords,
saves: SavePathLookup,
content?: PhotoContent,
): PhotosAPI => ({
byID: ({ fileID }): Photo | undefined => {
const records = derive();
const rec = records.photos.get(fileID);
const file = records.files.get(fileID);
return rec && file ? new Photo(rec, file, saves, content) : undefined;
const rec = derive().photos.get(fileID);
return rec ? new Photo(rec, content) : undefined;
},
records: ({ fileIDs }): PhotoRecord[] => {
const { photos } = derive();
+17 -43
View File
@@ -5,11 +5,10 @@
// owner ruling 5). The decrypted `Collection`/`EnteFile` objects stay in RAM in
// the main process; the window only ever sees these records.
//
// Ente holds edited/basic times in microseconds; records expose `takenAt` and
// `modifiedAt` in milliseconds. The magic-metadata field names below are the
// ones the Ente clients write, confirmed against the repo's own fixtures:
// `w`/`h` in test/cli/metadata-backup.test.ts, `visibility` in
// test/library/store.test.ts.
// Ente holds edited/basic times in microseconds; records expose `takenAt` in
// milliseconds. The magic-metadata field names below are the ones the Ente
// clients write, confirmed against the repo's own fixtures: `w`/`h` in
// test/cli/metadata-backup.test.ts, `visibility` in test/library/store.test.ts.
import type {
Collection,
@@ -34,17 +33,12 @@ export interface PhotoRecord {
// Milliseconds. `pubMagicMetadata.editedTime` when the user edited the
// date, else basic-metadata `creationTime`.
takenAt: number;
// Milliseconds. Basic-metadata `modificationTime`.
modifiedAt: number;
fileType: FileType;
caption?: string;
width?: number;
height?: number;
latitude?: number;
longitude?: number;
// The content hash the uploader recorded (`FileMetadata.hash`); files from
// very old clients have none.
hash?: string;
isArchived: boolean;
isHidden: boolean;
// Local cache paths, set once a later phase caches the bytes; unset here.
@@ -86,10 +80,6 @@ export interface LibraryChange {
export interface DerivedRecords {
albums: Map<number, AlbumRecord>;
photos: Map<number, PhotoRecord>;
// The membership each photo's record is read from, for its `Photo`'s save
// path. It holds the file's key, so it stays in this process: no snapshot
// or change carries it.
files: Map<number, EnteFile>;
}
const asString = (v: unknown): string | undefined =>
@@ -105,29 +95,10 @@ const microsToMillis = (micros: number): number => Math.floor(micros / 1000);
const byNewestPhoto = (a: PhotoRecord, b: PhotoRecord): number =>
b.takenAt - a.takenAt || b.fileID - a.fileID;
// A photo's `takenAt` in milliseconds: `pubMagicMetadata.editedTime` when the
// user edited the date, else basic-metadata `creationTime`.
export const takenAtOf = (file: EnteFile): number =>
microsToMillis(
asNumber(file.pubMagicMetadata?.editedTime) ??
file.metadata.creationTime,
);
// The membership a file's record is read from: the most recently synced, lowest
// collection id to break ties. Whatever dates a file's save path takes this
// membership too, so the path always carries the record's `takenAt`.
export const representative = (memberships: EnteFile[]): EnteFile =>
memberships.reduce((best, m) =>
m.updationTime > best.updationTime ||
(m.updationTime === best.updationTime &&
m.collectionID < best.collectionID)
? m
: best,
);
// Build one PhotoRecord from every membership of a file. The memberships share
// the same underlying file, so metadata is read from a single representative;
// `albumIDs` gathers them all.
// the same underlying file, so metadata is read from a single representative
// (the most recently synced, lowest collection id to break ties); `albumIDs`
// gathers them all.
const toPhotoRecord = (
fileID: number,
memberships: EnteFile[],
@@ -135,19 +106,25 @@ const toPhotoRecord = (
const albumIDs = memberships
.map((m) => m.collectionID)
.sort((a, b) => a - b);
const rep = representative(memberships);
const rep = memberships.reduce((best, m) =>
m.updationTime > best.updationTime ||
(m.updationTime === best.updationTime &&
m.collectionID < best.collectionID)
? m
: best,
);
const pub = rep.pubMagicMetadata ?? {};
const priv = rep.magicMetadata ?? {};
const takenAtMicros = asNumber(pub.editedTime) ?? rep.metadata.creationTime;
const visibility = asNumber(priv.visibility);
const record: PhotoRecord = {
fileID,
albumIDs,
title: asString(pub.editedName) ?? rep.metadata.title,
takenAt: takenAtOf(rep),
modifiedAt: microsToMillis(rep.metadata.modificationTime),
takenAt: microsToMillis(takenAtMicros),
fileType: rep.metadata.fileType,
isArchived: visibility === VISIBILITY_ARCHIVED,
isHidden: visibility === VISIBILITY_HIDDEN,
@@ -163,7 +140,6 @@ const toPhotoRecord = (
record.latitude = rep.metadata.latitude;
if (rep.metadata.longitude !== undefined)
record.longitude = rep.metadata.longitude;
if (rep.metadata.hash !== undefined) record.hash = rep.metadata.hash;
return record;
};
@@ -214,7 +190,6 @@ export const deriveRecords = (
}
const photos = new Map<number, PhotoRecord>();
const photoFiles = new Map<number, EnteFile>();
const takenAtByFile = new Map<number, number>();
for (const [fileID, memberships] of byFileID) {
const record = toPhotoRecord(fileID, memberships);
@@ -226,7 +201,6 @@ export const deriveRecords = (
record.thumbnailPath = paths.thumbnailPath;
}
photos.set(fileID, record);
photoFiles.set(fileID, representative(memberships));
takenAtByFile.set(fileID, record.takenAt);
}
@@ -235,7 +209,7 @@ export const deriveRecords = (
albums.set(c.id, toAlbumRecord(c, files, takenAtByFile));
}
return { albums, photos, files: photoFiles };
return { albums, photos };
};
// Sorted, GUI-ready arrays: albums newest updated first, photos newest first.
+10 -58
View File
@@ -2,11 +2,10 @@
//
// The store keeps one `metadata.json` file holding the account's server
// state: the user id, a schema version, the cursor for the incremental
// collections listing, the decrypted collection and file records, and the
// collections and files the refresh could not decrypt. The whole file is read
// into RAM on load and rewritten as a whole on save; there is no partial
// update and no lock file. A separate refresh unit populates the store from
// the server — this module only stores what it is given.
// collections listing, and the decrypted collection and file records. The
// whole file is read into RAM on load and rewritten as a whole on save; there
// is no partial update and no lock file. A separate refresh unit populates the
// store from the server — this module only stores what it is given.
//
// The file is a cache, so it is never trusted to exist or to be intact: a
// missing or unreadable file loads as an empty store rather than an error, and
@@ -17,7 +16,6 @@ import { dirname } from "node:path";
import { writeAtomic } from "../download/index.js";
import type { Collection, EnteFile, Microseconds } from "../model/types.js";
import { representative } from "./records.js";
// Bumped only when the on-disk shape changes incompatibly. A file written
// under a different version is discarded on load (see `load`): re-fetching
@@ -34,22 +32,12 @@ const FILE_MODE = 0o600;
type StoredCollection = Omit<Collection, "key"> & { key: string };
type StoredFile = Omit<EnteFile, "key"> & { key: string };
// A collection, or with `fileID` a file in it, that the last refresh to fetch
// it could not decrypt or parse, and why. It is kept until a later refresh
// fetches it again, because a refresh fetches only what changed.
export interface Undecryptable {
collectionID: number;
fileID?: number;
error: string;
}
interface StoredMetadata {
schemaVersion: number;
userID: number;
collectionsSinceTime: Microseconds;
collections: StoredCollection[];
files: StoredFile[];
undecryptable: Undecryptable[];
}
const encodeKey = (key: Uint8Array): string =>
@@ -78,7 +66,6 @@ export class MetadataStore {
private readonly collections = new Map<number, Collection>();
private readonly files = new Map<string, EnteFile>();
private undecryptable: Undecryptable[] = [];
private constructor(path: string) {
this.path = path;
@@ -119,12 +106,6 @@ export class MetadataStore {
};
store.files.set(fileKey(file.collectionID, file.id), file);
}
for (const record of parsed.undecryptable) {
if (typeof record.collectionID !== "number") {
throw new Error("damaged record of what did not decrypt");
}
store.undecryptable.push(record);
}
} catch {
// Any corruption discards the partial result: a half-read cache is
// worse than an empty one, since the refresh unit will rebuild it.
@@ -152,7 +133,6 @@ export class MetadataStore {
...f,
key: encodeKey(f.key),
})),
undecryptable: this.undecryptable,
};
const dir = dirname(this.path);
@@ -184,8 +164,7 @@ export class MetadataStore {
this.collections.set(collection.id, collection);
}
// 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
// Removing a collection also drops its file memberships: a file record is
// only meaningful as part of a collection the cache still knows about.
deleteCollection(id: number): void {
this.collections.delete(id);
@@ -194,25 +173,20 @@ export class MetadataStore {
this.files.delete(key);
}
}
this.undecryptable = this.undecryptable.filter(
(record) => record.collectionID !== id,
);
}
getFile(collectionID: number, fileID: number): EnteFile | undefined {
return this.files.get(fileKey(collectionID, fileID));
}
// The membership of a file its record is read from (`representative`), or
// undefined. Any membership could fetch the bytes, but the content cache
// resolves a fileID to a file this way so that it dates the save path
// from the same membership as `photo.savePath`.
// Any membership of a file, or undefined. Every membership re-wraps the
// same underlying content key, so any one is enough to fetch the bytes;
// the content cache resolves a fileID to a file this way.
getFileByID(fileID: number): EnteFile | undefined {
const memberships: EnteFile[] = [];
for (const file of this.files.values()) {
if (file.id === fileID) memberships.push(file);
if (file.id === fileID) return file;
}
return memberships.length > 0 ? representative(memberships) : undefined;
return undefined;
}
listFiles(collectionID: number): EnteFile[] {
@@ -228,26 +202,4 @@ export class MetadataStore {
deleteFile(collectionID: number, fileID: number): void {
this.files.delete(fileKey(collectionID, fileID));
}
listUndecryptable(): Undecryptable[] {
return [...this.undecryptable];
}
// Replaces any earlier record of the same collection or file.
putUndecryptable(record: Undecryptable): void {
this.deleteUndecryptable(record.collectionID, record.fileID);
this.undecryptable.push(record);
}
// Forgets the record of the collection, or with `fileID` of that file in
// it. Returns whether there was one.
deleteUndecryptable(collectionID: number, fileID?: number): boolean {
const before = this.undecryptable.length;
this.undecryptable = this.undecryptable.filter(
(record) =>
record.collectionID !== collectionID ||
record.fileID !== fileID,
);
return this.undecryptable.length < before;
}
}
+67 -16
View File
@@ -1,8 +1,8 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import * as jpeg from "jpeg-js";
import exifReader from "exif-reader";
import type { Client } from "./client.js";
import { readExifTags } from "./exif.js";
import type { Library, Photo } from "./library/index.js";
import { sanitizeFileName } from "./filename.js";
import {
@@ -19,10 +19,63 @@ export interface MetadataBackupOptions {
onProgress?: ProgressCallback;
}
// Extract dimensions, EXIF and XMP from a file's bytes. `exif` is the EXIF tags
// exifreader returns, from any image format it reads. When it finds an EXIF
// block but reads no tag from it, the record keeps the block's bytes, base64,
// in `exifRaw`, with the reason in `exifError`.
// Find the raw EXIF APP1 segment in JPEG bytes. Returns `exif` (the segment
// data, starting at the "Exif\0\0" header) when there is one, nothing when the
// bytes are not a JPEG or carry no EXIF, and `error` when the segment layout is
// malformed. Each segment length is checked against the bytes that remain and
// each step moves forward by at least 4 bytes, so the scan ends on any input.
export const extractExifFromJpeg = (
buf: Uint8Array,
): { exif?: Buffer; error?: string } => {
if (buf[0] !== 0xff || buf[1] !== 0xd8) return {};
let offset = 2;
while (offset < buf.length) {
if (offset + 2 > buf.length)
return { error: `truncated segment marker at byte ${offset}` };
if (buf[offset] !== 0xff)
return { error: `no segment marker at byte ${offset}` };
const marker = buf[offset + 1]!;
if (marker === 0xda) return {}; // start of scan, no more markers
if (offset + 4 > buf.length)
return { error: `truncated segment length at byte ${offset}` };
const len = (buf[offset + 2]! << 8) | buf[offset + 3]!;
// The length counts its own two bytes, so anything under 2 is invalid.
if (len < 2)
return {
error: `segment length ${len} at byte ${offset} is too small`,
};
if (offset + 2 + len > buf.length)
return {
error: `segment length ${len} at byte ${offset} runs past the end of the file`,
};
if (marker === 0xe1) {
// APP1 — check for "Exif\0\0" header. A length under 8 cannot hold
// the six-byte header, so the segment is not EXIF; below 6 the
// bytes compared would also lie past the segment.
if (
len >= 8 &&
buf[offset + 4] === 0x45 &&
buf[offset + 5] === 0x78 &&
buf[offset + 6] === 0x69 &&
buf[offset + 7] === 0x66
) {
return {
exif: Buffer.from(
buf.buffer,
buf.byteOffset + offset + 4,
len - 2,
),
};
}
}
offset += 2 + len;
}
return { error: "file ends before the image data" };
};
// Extract dimensions, EXIF and XMP from a file's bytes. When the EXIF segment
// is malformed or cannot be parsed, the record carries the reason in
// `exifError`.
export const extractImageMetadata = (
fileBytes: Uint8Array,
): Record<string, unknown> | undefined => {
@@ -39,21 +92,19 @@ export const extractImageMetadata = (
result.height = decoded.height;
} catch {
// Not every original is a JPEG (PNG, HEIC, video), so a failed decode
// is expected and only means no dimensions; unreadable EXIF is still
// is expected and only means no dimensions; a malformed JPEG is still
// reported below through `exifError`.
}
const tags = readExifTags(fileBytes);
if (tags?.exif && Object.keys(tags.exif).length > 0) {
result.exif = tags.exif;
} else if (tags?.exif) {
const block = tags.metadataRange?.blocks.find((b) => b.type === "exif");
if (block) {
result.exifRaw = Buffer.from(
fileBytes.subarray(block.start, block.end),
).toString("base64");
const { exif, error } = extractExifFromJpeg(fileBytes);
if (error) result.exifError = error;
if (exif) {
try {
result.exif = exifReader(exif);
} catch (err) {
result.exifRaw = exif.toString("base64");
result.exifError = err instanceof Error ? err.message : String(err);
}
result.exifError = "no tag could be read from the EXIF block";
}
// Extract XMP (look for "http://ns.adobe.com/xap" in the bytes)
-10
View File
@@ -37,16 +37,6 @@ export const DEFAULT_RETRY_OPTIONS: ResolvedRetryOptions = {
random: Math.random,
};
// For a run nobody is watching, such as `quak backup` from cron. A request that
// keeps failing waits at most 243 s in all before it gives up, usually about
// half that, since each wait is drawn at random below its ceiling.
export const UNATTENDED_RETRY_OPTIONS: ResolvedRetryOptions = {
...DEFAULT_RETRY_OPTIONS,
attempts: 10,
baseDelayMs: 1_000,
maxDelayMs: 60_000,
};
export const resolveRetryOptions = (
opts?: RetryOptions,
): ResolvedRetryOptions => ({
-595
View File
@@ -1,595 +0,0 @@
/**
* How many originals `lib.backup()` works on at once.
*
* The backup puts up to the content pool's limit of originals at their save
* paths at the same time: 5 for a library opened with the default
* `RequestPools`, as `quak backup` opens it, or the `contentConcurrency` of the
* pools a library was opened with. Everything the backup does for one original
* counts against that limit, hashing it for `verify` included, so however large
* the account is, the backup has no more than that many originals open at once.
*
* As in `backup-end-to-end.test.ts`, the real `Client` and `Library` run
* against `buildMetaFetch` from `test/ente-server.ts`, a fake of the Ente HTTP
* API serving data this file encrypts with libsodium. Here a test can also hold
* each original's download until it lets it go, to see how many the backup has
* in flight at once.
*
* The account, user 42, has one album of its own, Holiday, of eight photos,
* all taken at noon on 1 March 2024, each with an original of its own.
*/
import {
existsSync,
lstatSync,
mkdtempSync,
readdirSync,
readFileSync,
readlinkSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import sodium from "libsodium-wrappers-sumo";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { BackupOptions } from "../../src/backup.js";
import { Client, type ClientSnapshot } from "../../src/client.js";
import { init, toBase64 } from "../../src/crypto/index.js";
import { Library } from "../../src/library/index.js";
import { RequestPools } from "../../src/library/pools.js";
import {
buildAccount,
buildMetaFetch,
encryptSecretbox,
encryptStreamBlob,
} from "../ente-server.js";
import { blake2b } from "../live-photo.js";
const EMAIL = "backup@example.com";
const PASSWORD = "backup-password";
// The fake server logs every account in as user 42.
const USER_ID = 42;
// Noon on 1 March 2024 in the time zone of the machine running the test, in
// microseconds, as Ente records times.
const TAKEN = new Date(2024, 2, 1, 12).getTime() * 1000;
// The eight photos' IDs. Photo `id` is titled `photo-<id>.jpg`, and its
// original is a few bytes naming it.
const PHOTO_IDS = [101, 102, 103, 104, 105, 106, 107, 108];
const originalOf = (id: number): Uint8Array =>
new TextEncoder().encode(`the photo ${id}`);
// Where a backup to `dir` saves photo `id`'s original.
const savedAt = (dir: string, id: number): string =>
join(dir, "2024/2024-03/2024-03-01", `2024-03-01.${id}.jpg`);
// The fake server, the session it logs the account in with, and the
// directory every backup below is made in.
let server: typeof globalThis.fetch;
let session: ClientSnapshot;
let root: string;
// The fake server, holding the account encrypted as Ente's clients upload it.
const buildServer = async () => {
const account = await buildAccount(PASSWORD);
// The album as `/collections/v2` lists it: its key encrypted with the
// account's master key, its name with its own key.
const albumKey = sodium.crypto_secretbox_keygen();
const encryptedAlbumKey = encryptSecretbox(albumKey, account.masterKey);
const encryptedName = encryptSecretbox(
new TextEncoder().encode("Holiday"),
albumKey,
);
const album = {
id: 10,
owner: { id: USER_ID },
encryptedKey: toBase64(encryptedAlbumKey.ciphertext),
keyDecryptionNonce: toBase64(encryptedAlbumKey.nonce),
encryptedName: toBase64(encryptedName.ciphertext),
nameDecryptionNonce: toBase64(encryptedName.nonce),
type: "album",
updationTime: TAKEN,
};
// Each photo as the album's `/collections/v2/diff` lists it: its key
// encrypted with the album's key, and its metadata, holding the hash of
// its original, encrypted with its own key. Its original, encrypted with
// its own key too, is what the files CDN serves for it.
const ciphertexts: Record<number, Uint8Array> = {};
const photos = PHOTO_IDS.map((id) => {
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const original = encryptStreamBlob(originalOf(id), key);
ciphertexts[id] = original.ciphertext;
const encryptedKey = encryptSecretbox(key, albumKey);
const metadata = encryptStreamBlob(
new TextEncoder().encode(
JSON.stringify({
title: `photo-${id}.jpg`,
fileType: 0,
hash: blake2b(originalOf(id)),
creationTime: TAKEN,
modificationTime: TAKEN,
}),
),
key,
);
return {
id,
collectionID: 10,
ownerID: USER_ID,
encryptedKey: toBase64(encryptedKey.ciphertext),
keyDecryptionNonce: toBase64(encryptedKey.nonce),
metadata: {
encryptedData: toBase64(metadata.ciphertext),
decryptionHeader: toBase64(metadata.header),
},
file: { decryptionHeader: toBase64(original.header) },
thumbnail: {
decryptionHeader: toBase64(sodium.randombytes_buf(24)),
},
updationTime: TAKEN,
};
});
return buildMetaFetch({
verifier: account.verifier,
srpAttributes: account.srpAttributes,
keyAttributes: account.keyAttributes,
encryptedToken: account.encryptedToken,
collections: [album],
filesByCollection: { 10: photos },
encryptedMLData: {},
fileCiphertexts: ciphertexts,
});
};
beforeAll(async () => {
await init();
await sodium.ready;
root = mkdtempSync(join(tmpdir(), "quak-backup-concurrency-"));
server = await buildServer();
const client = await Client.login({
email: EMAIL,
password: PASSWORD,
apiOptions: { fetch: server },
});
session = client.toJSON();
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });
});
// The ID of the photo whose original a request downloads, or undefined for any
// other request. quak downloads an original from
// `https://files.ente.io/?fileID=<id>`.
const downloadOf = (input: RequestInfo | URL): number | undefined => {
const url = new URL(String(input));
return url.hostname === "files.ente.io"
? Number(url.searchParams.get("fileID"))
: undefined;
};
// The fake server, except that it answers 404 to every download of photo
// `failing`'s original.
const failingFor =
(failing: number): typeof globalThis.fetch =>
async (input, init) =>
downloadOf(input) === failing
? new Response("not found", { status: 404 })
: server(input, init);
// The fake server, holding each download of an original until the test lets
// it go. `held` lists the downloads waiting, the first to arrive first;
// `most()` is the largest number that ever waited at once. A held download of
// photo `failing`'s original is answered 404 once it is let go.
const holdingServer = (failing?: number) => {
const held: { id: number; release: () => void }[] = [];
let most = 0;
const fetch: typeof globalThis.fetch = async (input, init) => {
const id = downloadOf(input);
if (id !== undefined) {
await new Promise<void>((release) => {
held.push({ id, release });
most = Math.max(most, held.length);
});
if (id === failing) {
return new Response("not found", { status: 404 });
}
}
return server(input, init);
};
return { fetch, held, most: () => most };
};
// Let the held downloads go one at a time, the first to arrive first, each
// once `limit` of them wait, or all the downloads still to come when there
// are fewer. Each of the eight photos is downloaded once. With a backup that
// downloads fewer than `limit` at once, this fails waiting for them. Each wait
// allows five seconds, for a loaded machine's disk to sync the files written.
const releaseOneAtATime = async (
held: { release: () => void }[],
limit: number,
): Promise<void> => {
for (let left = PHOTO_IDS.length; left > 0; left--) {
await vi.waitFor(
() => expect(held).toHaveLength(Math.min(limit, left)),
{ interval: 5, timeout: 5000 },
);
held.shift()!.release();
}
};
// A library of the account opened as `quak backup` opens one, with no
// precache and no background refresh, sending its requests through `fetch`.
// It backs up to `dir` and keeps its cache beside it. Its request pools are
// `pools`, or by default the library's own.
const openLibrary = (
dir: string,
fetch: typeof globalThis.fetch,
pools?: RequestPools,
): Promise<Library> =>
Library.open({
client: Client.fromJSON(session, { fetch }),
downloadDirectory: dir,
cacheDirectory: `${dir}-cache`,
refreshIntervalSeconds: Infinity,
precacheThumbnails: false,
precacheOriginals: false,
pools,
});
// Every entry under `dir`, relative to it, sorted, a symlink as
// `name -> target`.
const listTree = (dir: string): string[] =>
readdirSync(dir, { recursive: true })
.sort()
.map((name) => {
const path = join(dir, name);
return lstatSync(path).isSymbolicLink()
? `${name} -> ${readlinkSync(path)}`
: name;
});
const expectStored = (dir: string, ids: number[]): void => {
for (const id of ids) {
expect(readFileSync(savedAt(dir, id))).toEqual(
Buffer.from(originalOf(id)),
);
}
};
describe("lib.backup()", () => {
// A library opened with the default pools, as `quak backup` opens one,
// downloads up to five originals at once. As soon as one finishes, the
// backup starts on the next photo, so five stay in flight until fewer
// than five are left.
it("downloads five originals at once, and never more, with the default pools", async () => {
const dir = mkdtempSync(join(root, "backup-"));
const holding = holdingServer();
const lib = await openLibrary(dir, holding.fetch);
try {
const backup = lib.backup();
await releaseOneAtATime(holding.held, 5);
const result = await backup;
expect(holding.most()).toBe(5);
expect(result).toMatchObject({
totalFiles: 8,
downloaded: 8,
failed: 0,
});
expectStored(dir, PHOTO_IDS);
} finally {
await lib.close();
}
});
// The limit is the content pool's, so a library opened with pools of
// another `contentConcurrency` backs up that many at once.
it("downloads as many originals at once as the content pool of the pools it was opened with allows", async () => {
const dir = mkdtempSync(join(root, "backup-"));
const holding = holdingServer();
const lib = await openLibrary(
dir,
holding.fetch,
new RequestPools({ contentConcurrency: 2 }),
);
try {
const backup = lib.backup();
await releaseOneAtATime(holding.held, 2);
const result = await backup;
expect(holding.most()).toBe(2);
expect(result).toMatchObject({ downloaded: 8, failed: 0 });
expectStored(dir, PHOTO_IDS);
} finally {
await lib.close();
}
});
// With `verify`, hashing an original already stored is part of the work
// on it, and counts against the limit too: a large backup never has more
// than five originals open for hashing. Here every stored original has
// changed on disk, so each is hashed, found not to match, and downloaded
// again. While five downloads wait, the backup has hashed only those five
// photos' originals; it opens the next one once a download finishes.
it("with verify, hashes no more originals ahead of their downloads than the limit", async () => {
const dir = mkdtempSync(join(root, "backup-"));
const first = await openLibrary(dir, server);
try {
await first.backup();
} finally {
await first.close();
}
for (const id of PHOTO_IDS) {
writeFileSync(savedAt(dir, id), "not the photo any more");
}
const holding = holdingServer();
const lib = await openLibrary(dir, holding.fetch);
const lines: string[] = [];
const mismatches = () =>
lines.filter((line) => line.startsWith("MISMATCH"));
try {
const backup = lib.backup({
verify: true,
onProgress: (line) => lines.push(line),
});
await vi.waitFor(() => expect(holding.held).toHaveLength(5), {
interval: 5,
timeout: 5000,
});
expect(mismatches()).toHaveLength(5);
await releaseOneAtATime(holding.held, 5);
const result = await backup;
expect(holding.most()).toBe(5);
expect(mismatches()).toHaveLength(8);
expect(result).toMatchObject({
downloaded: 8,
verified: 0,
mismatched: 8,
failed: 0,
});
expectStored(dir, PHOTO_IDS);
} finally {
await lib.close();
}
});
// The server answers 404 for photo 103's original. A 404 is not retried,
// so that download fails at once, while other downloads are still in
// flight. The backup records the failure and goes on: the downloads
// beside it finish, the photos after it are downloaded, and only photo
// 103 is missing from the backup and in failures.json.
it("goes on with the other originals when one fails", async () => {
const dir = mkdtempSync(join(root, "backup-"));
const holding = holdingServer(103);
const lib = await openLibrary(dir, holding.fetch);
try {
const backup = lib.backup();
await releaseOneAtATime(holding.held, 5);
const result = await backup;
expect(result).toMatchObject({
totalFiles: 8,
downloaded: 7,
failed: 1,
});
expect(result.errors.map((e) => e.fileID)).toEqual([103]);
expectStored(
dir,
PHOTO_IDS.filter((id) => id !== 103),
);
expect(existsSync(savedAt(dir, 103))).toBe(false);
const failures = JSON.parse(
readFileSync(join(dir, "failures.json"), "utf-8"),
);
expect(Object.keys(failures.files)).toEqual(["103"]);
} finally {
await lib.close();
}
});
// A throw from `onProgress` fails the backup: `lib.backup()` rejects with
// it. It rejects only once it has finished with every original it was
// working on, so nothing is written into the backup after it has ended
// and removed its lock. Here, with a content pool of eight, all eight
// downloads wait at once. Photo 103's is let go first and answered 404,
// and the line reporting that failure throws while the other seven wait.
it("when onProgress throws, rejects only once the originals it was working on are in place", async () => {
const dir = mkdtempSync(join(root, "backup-"));
const holding = holdingServer(103);
const lib = await openLibrary(
dir,
holding.fetch,
new RequestPools({ contentConcurrency: 8 }),
);
const lines: string[] = [];
try {
const backup = lib.backup({
onProgress: (line) => {
lines.push(line);
if (line.startsWith("FAILED")) {
throw new Error("the progress output is closed");
}
},
});
// The photos whose originals are in place, and whether the lock
// is there, at the moment `lib.backup()` rejects.
let storedWhenRejected: number[] = [];
let lockedWhenRejected = true;
const rejection = backup.catch((err: unknown) => {
storedWhenRejected = PHOTO_IDS.filter((id) =>
existsSync(savedAt(dir, id)),
);
lockedWhenRejected = existsSync(join(dir, "backup.lock"));
return err;
});
await vi.waitFor(() => expect(holding.held).toHaveLength(8), {
interval: 5,
timeout: 5000,
});
const at103 = holding.held.findIndex((h) => h.id === 103);
holding.held.splice(at103, 1)[0]!.release();
await vi.waitFor(
() =>
expect(
lines.some((line) => line.startsWith("FAILED")),
).toBe(true),
{ interval: 5, timeout: 5000 },
);
for (const download of holding.held) download.release();
expect(await rejection).toEqual(
new Error("the progress output is closed"),
);
expect(storedWhenRejected).toEqual(
PHOTO_IDS.filter((id) => id !== 103),
);
expect(lockedWhenRejected).toBe(false);
} finally {
await lib.close();
}
});
// Once the backup's lock is lost, another backup may be writing to the
// directory, so the backup starts no further original. It finishes the
// ones it is working on, and `lib.backup()` rejects once they are all in
// place. Here `backup.lock` is removed while the first five downloads
// wait, and proper-lockfile's timer finds it gone. As in
// `backup-end-to-end.test.ts`, only `setTimeout` and `clearTimeout` are
// faked, so the test can fire that timer without waiting 5 seconds. Only
// the first five downloads are held: a backup that went on would store
// the other three photos.
it("when its lock is lost, starts no further original, and rejects once the originals it was working on are in place", async () => {
const dir = mkdtempSync(join(root, "backup-"));
const lockPath = join(dir, "backup.lock");
const downloads: number[] = [];
const held: (() => void)[] = [];
const fetch: typeof globalThis.fetch = async (input, init) => {
const id = downloadOf(input);
if (id !== undefined) {
downloads.push(id);
if (downloads.length <= 5) {
await new Promise<void>((release) => held.push(release));
}
}
return server(input, init);
};
const lib = await openLibrary(dir, fetch);
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
const backup = lib.backup();
// The photos whose originals are in place at the moment
// `lib.backup()` rejects.
let storedWhenRejected: number[] = [];
const rejection = backup.catch((err: unknown) => {
storedWhenRejected = PHOTO_IDS.filter((id) =>
existsSync(savedAt(dir, id)),
);
return err;
});
await vi.waitFor(() => expect(held).toHaveLength(5), {
interval: 5,
timeout: 5000,
});
rmSync(lockPath, { recursive: true });
// Fire proper-lockfile's timer, and wait for the look at the lock
// on disk that it starts to finish: Node lists a file system call
// in progress as an `FSReqCallback`.
vi.advanceTimersByTime(5_000);
while (process.getActiveResourcesInfo().includes("FSReqCallback")) {
await new Promise((resolve) => setImmediate(resolve));
}
for (const release of held) release();
expect(await rejection).toEqual(
new Error(
`lost the lock on ${dir}: ENOENT: no such file or directory, stat '${lockPath}'`,
),
);
expect(downloads.sort()).toEqual([101, 102, 103, 104, 105]);
expect(storedWhenRejected).toEqual([101, 102, 103, 104, 105]);
} finally {
vi.useRealTimers();
await lib.close();
}
});
// Working on several originals at once changes only how long a backup
// takes. The same two runs, with a content pool of one, which backs up
// one original at a time, and with the default pools, end the same way:
// the same result, the same progress lines (in another order), the same
// failures.json, and the same tree. The first run stores every photo but
// 103, whose download is answered 404. Then two stored originals change
// on disk, and a run with `verify` puts them back and tries 103 again.
it("ends as a backup of one original at a time does", async () => {
const twoRuns = async (pools: RequestPools) => {
const dir = mkdtempSync(join(root, "backup-"));
const lines: string[] = [];
const backup = async (opts: BackupOptions) => {
const lib = await openLibrary(dir, failingFor(103), pools);
try {
return await lib.backup({
...opts,
onProgress: (line) => lines.push(line),
});
} finally {
await lib.close();
}
};
const first = await backup({});
writeFileSync(savedAt(dir, 101), "not the photo any more");
writeFileSync(savedAt(dir, 105), "not the photo any more");
const second = await backup({ verify: true });
// Each entry of failures.json without the time it was last
// tried, which differs from run to run.
const { files } = JSON.parse(
readFileSync(join(dir, "failures.json"), "utf-8"),
) as { files: Record<string, { lastTriedAt?: number }> };
for (const entry of Object.values(files)) delete entry.lastTriedAt;
return {
first,
second,
lines: lines.sort(),
failures: files,
tree: listTree(dir),
};
};
const oneAtATime = await twoRuns(
new RequestPools({ contentConcurrency: 1 }),
);
const fiveAtATime = await twoRuns(new RequestPools());
expect(oneAtATime.first).toMatchObject({
totalFiles: 8,
downloaded: 7,
skipped: 0,
failed: 1,
});
expect(oneAtATime.second).toMatchObject({
totalFiles: 8,
downloaded: 2,
skipped: 5,
verified: 5,
mismatched: 2,
unchecked: 0,
failed: 1,
});
expect(oneAtATime.failures).toEqual({
"103": expect.objectContaining({ fileID: 103, attempts: 2 }),
});
expect(fiveAtATime).toEqual(oneAtATime);
});
});
-835
View File
@@ -1,835 +0,0 @@
/**
* `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. Two tests run `lib.backup()` instead, as a program
* using quak as a library does.
*
* 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,
mkdirSync,
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,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} 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 { Library, lockBackupDirectory } from "../../src/library/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");
});
});
// `backup.lock` is a directory that proper-lockfile touches every 5 seconds,
// from a timer, while the backup runs. Each time, it first looks at the lock
// on disk. The lock is lost when it is gone, or when another backup has made
// its own in its place, as one does with a lock nobody touched for 10
// seconds. Another backup may then be writing to the directory, so the backup
// stops before it starts another file.
//
// The fake server holds one file's download open until the test lets it go,
// so the lock can be lost while the backup runs: beach.jpg's, the first file
// backed up, unless the test names another. Only `setTimeout` and
// `clearTimeout` are faked, so a test can fire proper-lockfile's timer without
// waiting 5 seconds.
describe("a backup whose lock is lost", () => {
let dir: string;
let lockPath: string;
let downloads: number[];
let heldFile: number;
let heldRequested: Promise<void>;
let releaseHeld: () => void;
let fetch: typeof globalThis.fetch;
beforeEach(() => {
dir = mkdtempSync(join(root, "backup-"));
lockPath = join(dir, "backup.lock");
downloads = [];
heldFile = BEACH.id;
let requested!: () => void;
heldRequested = new Promise((resolve) => {
requested = resolve;
});
const released = new Promise<void>((resolve) => {
releaseHeld = resolve;
});
const recording = recordingDownloads(downloads);
fetch = async (input, init) => {
if (downloadOf(input) === heldFile) {
requested();
await released;
}
return recording(input, init);
};
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
});
afterEach(() => {
vi.useRealTimers();
});
// Fire proper-lockfile's timer, and wait for the look at the lock on disk
// that it starts to finish: Node lists a file system call in progress as
// an `FSReqCallback`.
const fireLockTimer = async (): Promise<void> => {
vi.advanceTimersByTime(5_000);
while (process.getActiveResourcesInfo().includes("FSReqCallback")) {
await new Promise((resolve) => setImmediate(resolve));
}
};
// The backup downloads up to five originals at once, so all three are
// under way when the lock is lost, and beach.jpg is stored. `quak backup`
// prints why as one line, with no stack trace, and exits 1.
it("stops quak backup before its next file when backup.lock is removed, and exits 1", async () => {
const result = backup(dir, {}, fetch);
await heldRequested;
rmSync(lockPath, { recursive: true });
await fireLockTimer();
releaseHeld();
const { code, stderr } = await result;
expect(code).toBe(1);
expect(stderr).toBe(
"Starting backup...\n" +
"Refreshing library...\n" +
"Fetching original beach.jpg (100)...\n" +
"Fetching original IMG_0002.HEIC (300)...\n" +
"Fetching original toast.mp4 (200)...\n" +
"Fetching ML data...\n" +
`quak: lost the lock on ${dir}: ENOENT: no such file or directory, stat '${lockPath}'\n`,
);
expect(downloads.sort()).toEqual([100, 200, 300]);
expect(readFileSync(join(dir, DAY, "2024-03-01.100.jpg"))).toEqual(
Buffer.from(JPEG_WITH_EXIF),
);
});
// Here the lock is lost while toast.mp4, the last original, downloads.
// All three originals are stored, beside what the backup wrote before it
// started them, but no file's JSON and no album is written.
it("stops quak backup before any file's JSON when the lock is lost during the last original", async () => {
heldFile = TOAST_VIDEO.id;
const result = backup(dir, {}, fetch);
await heldRequested;
rmSync(lockPath, { recursive: true });
await fireLockTimer();
releaseHeld();
const { code, stderr } = await result;
expect(code).toBe(1);
expect(stderr).toContain(`quak: lost the lock on ${dir}: `);
expect(listTree(dir)).toEqual([
"2024",
"2024/2024-03",
"2024/2024-03/2024-03-01",
"2024/2024-03/2024-03-01/2024-03-01.100.jpg",
"2024/2024-03/2024-03-01/2024-03-01.200.mp4",
"2024/2024-03/2024-03-01/2024-03-01.300.heic",
"2024/2024-03/2024-03-01/2024-03-01.300.livephoto.json",
"2024/2024-03/2024-03-01/2024-03-01.300.mov",
"account.json",
"collections",
]);
});
// Nothing touches the lock but quak, which finds it as it left it each
// time it looks, so the backup runs to the end and removes its lock.
it("lets quak backup run to the end while its lock stays", async () => {
const result = backup(dir, {}, fetch);
await heldRequested;
await fireLockTimer();
await fireLockTimer();
releaseHeld();
const { code } = await result;
expect(code).toBe(0);
expect(downloads.sort()).toEqual([100, 200, 300]);
expect(existsSync(lockPath)).toBe(false);
});
// The library a program using quak opens to back `dir` up.
const openLibrary = () =>
Library.open({
client: loadSession(join(sessionDir, "session.json"), { fetch })!,
cacheDirectory: `${dir}-cache`,
downloadDirectory: dir,
refreshIntervalSeconds: Infinity,
precacheThumbnails: false,
precacheOriginals: false,
});
// A program using quak as a library sees the same: `lib.backup()` rejects
// with the reason. Here another backup has made its own lock in place of
// quak's, and that lock is left where it is.
it("rejects lib.backup() when another backup has taken the lock over", async () => {
const lib = await openLibrary();
try {
const result = lib.backup();
await heldRequested;
rmSync(lockPath, { recursive: true });
mkdirSync(lockPath);
await fireLockTimer();
releaseHeld();
await expect(result).rejects.toThrow(
`lost the lock on ${dir}: Unable to update lock within the stale threshold`,
);
expect(downloads.sort()).toEqual([100, 200, 300]);
expect(existsSync(lockPath)).toBe(true);
} finally {
await lib.close();
}
});
// A program can take the lock before it opens its library, as `quak
// backup` does: `lockBackupDirectory` returns the lock, which the program
// passes to `lib.backup()` and releases once the backup ends. The backup
// checks it as it checks a lock it took, and the lock's `lost` says why it
// was lost.
it("rejects lib.backup() given its caller's lock once that lock is lost", async () => {
const lock = await lockBackupDirectory(dir);
const lib = await openLibrary();
try {
const result = lib.backup({ lock });
await heldRequested;
rmSync(lockPath, { recursive: true });
await fireLockTimer();
releaseHeld();
const lost = `lost the lock on ${dir}: ENOENT: no such file or directory, stat '${lockPath}'`;
await expect(result).rejects.toThrow(lost);
expect(lock.lost?.message).toBe(lost);
expect(downloads.sort()).toEqual([100, 200, 300]);
} finally {
await lib.close();
await lock.release();
}
});
});
-554
View File
@@ -1,554 +0,0 @@
/**
* `quak backup` over an account holding an album or a file quak cannot decrypt
* (issue 181).
*
* The server sends every album and file encrypted, and a refresh decrypts each
* one it fetches. A record whose key no key of the account opens, or whose
* metadata is not JSON, must not fail the refresh: a refresh that fails commits
* nothing, so `quak backup` would store nothing, on every run, until the
* server's copy changed. Instead the refresh leaves that record out, and the
* library keeps a list of what it left out in its cache. It has to: the next
* refresh asks the server only for what changed since the last one, so it will
* not see the record again.
*
* The backup stores every other file. It records each file it could not
* decrypt in `failures.json`, prints one line for each album it could not
* decrypt, and exits 1 while any remain. Once the server sends a copy that
* decrypts, or deletes the record, the next run drops it and can exit 0. The
* read commands answer from what did decrypt.
*
* These tests run the commands with a real saved session over a stand-in Ente
* server, which serves albums, files and originals encrypted with libsodium as
* the real server does. A record is broken the way a corrupt one arrives: its
* key replaced by bytes no key opens, or its metadata text that is not JSON.
*/
import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import sodium from "libsodium-wrappers-sumo";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
backupCommand,
collectionsCommand,
filesCommand,
saveSession,
type CliContext,
} from "../../src/cli-commands.js";
import { loadSession } from "../../src/cli-session.js";
import { Client, type ClientSnapshot } from "../../src/client.js";
import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js";
import { Library } from "../../src/library/index.js";
const USER_ID = 42;
// Every file is taken at noon local time on 2026-03-01, in microseconds as Ente
// stores times, so its original is saved in the folder `DAY` whatever the time
// zone of the machine running the tests.
const TAKEN = new Date(2026, 2, 1, 12).getTime() * 1000;
const DAY = join("2026", "2026-03", "2026-03-01");
type Row = { id: number; updationTime: number } & Record<string, unknown>;
// `plaintext` sealed under `key` with a fresh nonce, both base64, as Ente
// sends a key or an album name.
const secretbox = (
plaintext: Uint8Array,
key: Uint8Array,
): { ciphertext: string; nonce: string } => {
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
return {
ciphertext: toBase64(
sodium.crypto_secretbox_easy(plaintext, nonce, key),
),
nonce: toBase64(nonce),
};
};
// What a corrupt row carries in place of its sealed key: random bytes of the
// same length, which no key opens.
const corruptKey = (): string =>
toBase64(
sodium.randombytes_buf(
sodium.crypto_secretbox_KEYBYTES + sodium.crypto_secretbox_MACBYTES,
),
);
const json = (body: unknown): Response =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
// A stand-in Ente server for one account. It holds each album, and each file in
// an album, as the row `/collections/v2` and `/collections/v2/diff` serve, and
// answers a request with the rows whose `updationTime` is past the request's
// `sinceTime`, as the real server does. A change to a file also moves its
// album's `updationTime`, which is what tells a client to list the album's
// files again. Originals are served by file ID, as the files CDN serves them.
class FakeEnte {
private readonly masterKey = sodium.crypto_secretbox_keygen();
private readonly keyPair = sodium.crypto_box_keypair();
private readonly albums = new Map<number, Row>();
private readonly albumKeys = new Map<number, Uint8Array>();
// Each album's file rows, by album ID and then file ID.
private readonly files = new Map<number, Map<number, Row>>();
// Each file's key and its original as served, by file ID. A file in two
// albums is one original under one key, sealed under each album's key.
private readonly originals = new Map<
number,
{ key: Uint8Array; header: string; body: Uint8Array }
>();
// The session `quak login` would have saved for the account.
snapshot(): ClientSnapshot {
return {
email: "user@example.com",
userID: USER_ID,
token: "token",
masterKey: toBase64(this.masterKey),
secretKey: toBase64(this.keyPair.privateKey),
publicKey: toBase64(this.keyPair.publicKey),
};
}
// Album `id`, named `name`, as of time `t`. With `corrupt`, its key cannot
// be decrypted. An album keeps its key from one call to the next, so its
// files' rows still decrypt once it is put back without `corrupt`.
putAlbum(id: number, name: string, t: number, corrupt = false): void {
const key = this.albumKeys.get(id) ?? sodium.crypto_secretbox_keygen();
this.albumKeys.set(id, key);
const sealedKey = secretbox(key, this.masterKey);
const sealedName = secretbox(new TextEncoder().encode(name), key);
this.albums.set(id, {
id,
owner: { id: USER_ID },
encryptedKey: corrupt ? corruptKey() : sealedKey.ciphertext,
keyDecryptionNonce: sealedKey.nonce,
encryptedName: sealedName.ciphertext,
nameDecryptionNonce: sealedName.nonce,
type: "album",
updationTime: t,
});
if (!this.files.has(id)) this.files.set(id, new Map());
}
// File `id`, titled `photo-<id>.jpg`, in album `albumID` as of time `t`.
// With `corruptKey`, its key cannot be decrypted. With `metadata`, its
// metadata decrypts to that text instead of the JSON a client uploads.
putFile(
albumID: number,
id: number,
t: number,
opts: { corruptKey?: boolean; metadata?: string } = {},
): void {
const original = this.originalOf(id);
const sealedKey = secretbox(original.key, this.albumKeys.get(albumID)!);
const metadata =
opts.metadata ??
JSON.stringify({
title: `photo-${id}.jpg`,
fileType: 0,
creationTime: TAKEN,
modificationTime: TAKEN,
});
const sealedMetadata = encryptBlob(
new TextEncoder().encode(metadata),
original.key,
);
this.files.get(albumID)!.set(id, {
id,
collectionID: albumID,
ownerID: USER_ID,
encryptedKey: opts.corruptKey ? corruptKey() : sealedKey.ciphertext,
keyDecryptionNonce: sealedKey.nonce,
metadata: {
encryptedData: toBase64(sealedMetadata.ciphertext),
decryptionHeader: toBase64(sealedMetadata.header),
},
file: { decryptionHeader: original.header },
thumbnail: { decryptionHeader: original.header },
updationTime: t,
});
this.moveAlbum(albumID, t);
}
// A deleted record stays on the server as a tombstone holding only its
// ID, its time and `isDeleted`.
deleteFile(albumID: number, id: number, t: number): void {
this.files
.get(albumID)!
.set(id, { id, updationTime: t, isDeleted: true });
this.moveAlbum(albumID, t);
}
deleteAlbum(id: number, t: number): void {
this.albums.set(id, { id, updationTime: t, isDeleted: true });
}
// The `fetch` the saved session's client is given.
readonly fetch = (async (input: string | URL | Request) => {
const url = new URL(input instanceof Request ? input.url : input);
const since = Number(url.searchParams.get("sinceTime"));
const changed = (rows: Iterable<Row>): Row[] =>
[...rows].filter((row) => row.updationTime > since);
switch (url.pathname) {
case "/collections/v2":
return json({ collections: changed(this.albums.values()) });
case "/collections/v2/diff": {
const albumID = Number(url.searchParams.get("collectionID"));
const rows = this.files.get(albumID)?.values() ?? [];
return json({ diff: changed(rows), hasMore: false });
}
case "/files/data/fetch":
// No file has ML data.
return json({ data: [] });
default: {
const fileID = Number(url.searchParams.get("fileID"));
const original = this.originals.get(fileID);
return original
? new Response(original.body)
: new Response("not found", { status: 404 });
}
}
}) as typeof globalThis.fetch;
private originalOf(id: number): {
key: Uint8Array;
header: string;
body: Uint8Array;
} {
const known = this.originals.get(id);
if (known) return known;
const key = sodium.crypto_secretbox_keygen();
const { header, ciphertext } = encryptBlob(
new TextEncoder().encode(`original of ${id}`),
key,
);
const original = { key, header: toBase64(header), body: ciphertext };
this.originals.set(id, original);
return original;
}
private moveAlbum(albumID: number, t: number): void {
this.albums.set(albumID, {
...this.albums.get(albumID)!,
updationTime: t,
});
}
}
// Album 1, "Trip", holds file 10, which decrypts, file 11, whose key does not,
// and file 12, whose metadata is not JSON. Album 2, "Family", holds file 20,
// but the album's own key does not decrypt. All of it as of time 1.
const brokenAccount = (): FakeEnte => {
const ente = new FakeEnte();
ente.putAlbum(1, "Trip", 1);
ente.putFile(1, 10, 1);
ente.putFile(1, 11, 1, { corruptKey: true });
ente.putFile(1, 12, 1, { metadata: "not json" });
ente.putAlbum(2, "Family", 1, true);
ente.putFile(2, 20, 1);
return ente;
};
// Album 1, "Trip", holds file 10, and album 2, "Family", holds file 20. Both
// files decrypt, but album 2's own key does not, so the album is the only
// record left out. All of it as of time 1.
const brokenAlbumAccount = (): FakeEnte => {
const ente = new FakeEnte();
ente.putAlbum(1, "Trip", 1);
ente.putFile(1, 10, 1);
ente.putAlbum(2, "Family", 1, true);
ente.putFile(2, 20, 1);
return ente;
};
// Collects everything written to it.
class Output {
text = "";
write(text: string): void {
this.text += text;
}
}
let root: string;
let stdout: Output;
let stderr: Output;
beforeAll(async () => {
await init();
await sodium.ready;
});
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "quak-undecryptable-"));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
// A context for one command, with fresh output, whose saved session talks to
// `ente`. Every run shares one cache directory, as runs of `quak` do.
const context = (ente: FakeEnte): CliContext => {
stdout = new Output();
stderr = new Output();
saveSession(join(root, "session"), ente.snapshot());
return {
stdout,
stderr,
sessionDir: join(root, "session"),
cacheDir: join(root, "cache"),
loadSession: (path) =>
loadSession(path, { fetch: ente.fetch, retry: { attempts: 1 } }),
login: async () => {
throw new Error("login not expected");
},
prompt: async () => {
throw new Error("prompt not expected");
},
promptSecret: async () => {
throw new Error("prompt not expected");
},
};
};
const backupDir = (): string => join(root, "backup");
// Runs `quak backup` into the backup directory and returns its exit code.
const backup = (ente: FakeEnte, opts: { json?: boolean } = {}) =>
backupCommand(context(ente), backupDir(), opts);
// The backup's `failures.json` entries by file ID, or {} when it has none.
const failures = (): Record<string, { error: string; attempts: number }> => {
const path = join(backupDir(), "failures.json");
return existsSync(path)
? JSON.parse(readFileSync(path, "utf-8")).files
: {};
};
// Whether file `id`'s original is at its save path in the backup.
const stored = (id: number): boolean =>
existsSync(join(backupDir(), DAY, `2026-03-01.${id}.jpg`));
// The IDs of the files that album `name`'s JSON lists.
const albumFiles = (name: string): number[] =>
JSON.parse(
readFileSync(join(backupDir(), "collections", `${name}.json`), "utf-8"),
).files.map((f: { id: number }) => f.id);
describe("quak backup with records it cannot decrypt", () => {
it("backs up every other file, records each file it cannot decrypt in failures.json, names each such album, and exits 1", async () => {
expect(await backup(brokenAccount())).toBe(1);
expect(stored(10)).toBe(true);
// Without its album's key, file 20 cannot even be listed.
expect(stored(20)).toBe(false);
expect(failures()).toEqual({
"11": expect.objectContaining({
fileID: 11,
error: expect.stringMatching(
/^could not be decrypted: .*wrong secret key/,
),
}),
"12": expect.objectContaining({
fileID: 12,
error: expect.stringMatching(/^could not be decrypted: .*JSON/),
}),
});
expect(stderr.text).toMatch(
/^FAILED album 2: could not be decrypted: .*wrong secret key/m,
);
expect(stderr.text).toContain(" Failed: 2\n");
});
it("names each album it cannot decrypt in the --json result", async () => {
expect(await backup(brokenAccount(), { json: true })).toBe(1);
expect(JSON.parse(stdout.text)).toMatchObject({
downloaded: 1,
failed: 2,
undecryptableAlbums: [2],
});
});
it("still reports them on the next run, though the server does not send them again", async () => {
const ente = brokenAccount();
await backup(ente);
// Nothing changed on the server, so this run's refresh gets no rows at
// all. What it reports, it remembered from the first run.
expect(await backup(ente)).toBe(1);
expect(Object.keys(failures())).toEqual(["11", "12"]);
expect(failures()["11"]!.attempts).toBe(2);
expect(stderr.text).toMatch(/^FAILED album 2: could not be decrypted/m);
});
it("drops each once the server sends a copy that decrypts, and then exits 0", async () => {
const ente = brokenAccount();
await backup(ente);
// At time 2 files 11 and 12 and album 2 change on the server, and now
// each decrypts. Album 2 brings file 20 with it.
ente.putFile(1, 11, 2);
ente.putFile(1, 12, 2);
ente.putAlbum(2, "Family", 2);
expect(await backup(ente)).toBe(0);
expect([11, 12, 20].map(stored)).toEqual([true, true, true]);
expect(existsSync(join(backupDir(), "failures.json"))).toBe(false);
expect(stderr.text).not.toContain("FAILED");
});
it("drops each once the server deletes it, and then exits 0", async () => {
const ente = brokenAccount();
await backup(ente);
ente.deleteFile(1, 11, 2);
ente.deleteFile(1, 12, 2);
ente.deleteAlbum(2, 2);
expect(await backup(ente)).toBe(0);
expect(existsSync(join(backupDir(), "failures.json"))).toBe(false);
expect(stderr.text).not.toContain("FAILED");
});
it("exits 1 when the only record it cannot decrypt is an album, and 0 once the album decrypts", async () => {
const ente = brokenAlbumAccount();
// No file is in `failures.json`: the album alone keeps the exit code
// at 1.
expect(await backup(ente)).toBe(1);
expect(failures()).toEqual({});
ente.putAlbum(2, "Family", 2);
expect(await backup(ente)).toBe(0);
expect(stored(20)).toBe(true);
});
it("exits 1 when the only record it cannot decrypt is an album, and 0 once the album is deleted", async () => {
const ente = brokenAlbumAccount();
expect(await backup(ente)).toBe(1);
expect(failures()).toEqual({});
ente.deleteAlbum(2, 2);
expect(await backup(ente)).toBe(0);
});
it("removes what an earlier run linked for an album or a file that no longer decrypts, and keeps the originals", async () => {
// At time 1 everything decrypts: the first run links files 10 and 11
// in Trip and file 20 in Family.
const ente = new FakeEnte();
ente.putAlbum(1, "Trip", 1);
ente.putFile(1, 10, 1);
ente.putFile(1, 11, 1);
ente.putAlbum(2, "Family", 1);
ente.putFile(2, 20, 1);
expect(await backup(ente)).toBe(0);
const collections = join(backupDir(), "collections");
expect(readdirSync(join(collections, "Trip")).sort()).toEqual([
"photo-10.jpg",
"photo-11.jpg",
]);
expect(readdirSync(join(collections, "Family"))).toEqual([
"photo-20.jpg",
]);
// At time 2 the server's copy of file 11 in Trip, and album 2 itself,
// no longer decrypt. A run builds the album folders, their JSON and
// their links from what the library holds, so it removes them as if
// the album and the file had been deleted: Family's folder and JSON
// go, and Trip neither links nor lists file 11. The originals stay at
// their save paths, and the run exits 1.
ente.putFile(1, 11, 2, { corruptKey: true });
ente.putAlbum(2, "Family", 2, true);
expect(await backup(ente)).toBe(1);
expect(readdirSync(collections).sort()).toEqual(["Trip", "Trip.json"]);
expect(readdirSync(join(collections, "Trip"))).toEqual([
"photo-10.jpg",
]);
expect(albumFiles("Trip")).toEqual([10]);
expect([10, 11, 20].map(stored)).toEqual([true, true, true]);
expect(Object.keys(failures())).toEqual(["11"]);
});
it("backs up a file that does not decrypt in one album but does in another, and does not report it", async () => {
// File 30 is in albums 1 and 3, each holding its key sealed under the
// album's own key. In album 1 that is corrupt; in album 3 it is not,
// and one copy that decrypts is all the backup needs.
const ente = new FakeEnte();
ente.putAlbum(1, "Trip", 1);
ente.putFile(1, 30, 1, { corruptKey: true });
ente.putAlbum(3, "Favorites", 1);
ente.putFile(3, 30, 1);
expect(await backup(ente)).toBe(0);
expect(stored(30)).toBe(true);
expect(failures()).toEqual({});
// Album 1 has no copy of file 30 that decrypts, so it neither links
// the original nor lists the file; album 3 lists it.
expect(readdirSync(join(backupDir(), "collections", "Trip"))).toEqual(
[],
);
expect(albumFiles("Trip")).toEqual([]);
expect(albumFiles("Favorites")).toEqual([30]);
});
it("backs up that file in a run limited to the album where it does not decrypt", async () => {
// The same account, backed up by `lib.backup()` limited to album 1,
// "Trip", as `quak backup` cannot be. File 30 is in Trip, so the run
// must store it, from its copy in album 3 that the run otherwise
// skips.
const ente = new FakeEnte();
ente.putAlbum(1, "Trip", 1);
ente.putFile(1, 30, 1, { corruptKey: true });
ente.putAlbum(3, "Favorites", 1);
ente.putFile(3, 30, 1);
const lib = await Library.open({
client: Client.fromJSON(ente.snapshot(), {
fetch: ente.fetch,
retry: { attempts: 1 },
}),
downloadDirectory: backupDir(),
cacheDirectory: join(root, "cache"),
precacheThumbnails: false,
precacheOriginals: false,
});
try {
const result = await lib.backup({
downloadDirectory: backupDir(),
onlyAlbumNames: ["Trip"],
});
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
} finally {
await lib.close();
}
expect(stored(30)).toBe(true);
expect(failures()).toEqual({});
});
});
describe("the read commands with records quak cannot decrypt", () => {
it("quak collections and quak files answer from what decrypts", async () => {
const ente = brokenAccount();
expect(await collectionsCommand(context(ente), {})).toBe(0);
expect(stdout.text).toBe("1\talbum\tTrip\n");
expect(await filesCommand(context(ente), { collection: "1" })).toBe(0);
expect(stdout.text).toBe("10\timage\tphoto-10.jpg\n");
});
});
+147 -882
View File
File diff suppressed because it is too large Load Diff
-71
View File
@@ -1,71 +0,0 @@
/**
* 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]);
});
});
+28 -466
View File
@@ -18,14 +18,12 @@ import {
readFileSync,
rmSync,
statSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { PassThrough } from "node:stream";
import * as jpegJs from "jpeg-js";
import sodium from "libsodium-wrappers-sumo";
import {
describe,
it,
@@ -54,14 +52,12 @@ import {
import { run } from "../../src/cli-run.js";
import { loadSession } from "../../src/cli-session.js";
import type { Client, ClientSnapshot, LoginOptions } from "../../src/client.js";
import { savePath, type ContentSource } from "../../src/library/content.js";
import type { ContentSource } from "../../src/library/content.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js";
import { init, toBase64 } from "../../src/crypto/index.js";
import { defaultCacheDirectory } from "../../src/library/index.js";
import { HEIC_WITH_EXIF } from "../exif-heic.js";
import {
asLivePhoto,
blake2b,
cdnSource,
IMAGE,
livePhotoHash,
@@ -233,30 +229,20 @@ describe("session file", () => {
expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(snapshot);
});
it("a missing session exits 3 with 'Not logged in' from every command that needs one", async () => {
it("a missing session exits 1 with 'Not logged in'", async () => {
const ctx = { ...context(), loadSession };
const dir = join(root, "backup");
expect(await whoamiCommand(ctx)).toBe(3);
expect(await collectionsCommand(ctx, {})).toBe(3);
expect(await filesCommand(ctx, { collection: "1" })).toBe(3);
expect(await getCommand(ctx, "100", {})).toBe(3);
expect(await getThumbCommand(ctx, "100", {})).toBe(3);
expect(await backupMetadataCommand(ctx, dir, {})).toBe(3);
expect(await backupCommand(ctx, dir, {})).toBe(3);
expect(await listMissingThumbnailsCommand(ctx, {})).toBe(3);
expect(await fixMissingThumbnailsCommand(ctx, {})).toBe(3);
const notLoggedIn =
expect(await whoamiCommand(ctx)).toBe(1);
expect(stderr.text).toBe(
`Not logged in. Run "quak login" first.\n` +
`Session file: ${join(ctx.sessionDir, "session.json")}\n`;
expect(stderr.text).toBe(notLoggedIn.repeat(9));
`Session file: ${join(ctx.sessionDir, "session.json")}\n`,
);
expect(stdout.text).toBe("");
expect(existsSync(dir)).toBe(false);
});
it("a corrupt session exits 3 and says it is corrupt", async () => {
it("a corrupt session exits 1 and says it is corrupt", async () => {
const ctx = { ...context(), loadSession };
saveSession(ctx.sessionDir, snapshot);
expect(await collectionsCommand(ctx, {})).toBe(3);
expect(await collectionsCommand(ctx, {})).toBe(1);
expect(stderr.text).toContain("is corrupt");
expect(stderr.text).toContain(
`Run "quak logout" and then "quak login" to replace it.\n`,
@@ -512,6 +498,11 @@ describe("files", () => {
expect(await filesCommand(context(), { collection: "9" })).toBe(1);
expect(stderr.text).toBe("Collection 9 not found\n");
});
it("exits 1 for a collection ID that is not a number", async () => {
expect(await filesCommand(context(), { collection: "abc" })).toBe(1);
expect(stderr.text).toBe("Invalid collection ID\n");
});
});
describe("get and get-thumb", () => {
@@ -542,6 +533,12 @@ describe("get and get-thumb", () => {
expect(stderr.text).toBe("File 999 not found\n");
expect(existsSync(out)).toBe(false);
});
it("both exit 1 for a file ID that is not a number", async () => {
expect(await getCommand(context(), "abc", {})).toBe(1);
expect(await getThumbCommand(context(), "abc", {})).toBe(1);
expect(stderr.text).toBe("Invalid file ID\nInvalid file ID\n");
});
});
// A live photo, which Ente stores as one ZIP, is written as its image and its
@@ -656,31 +653,6 @@ describe("a live photo", () => {
height: 4,
});
});
it("backup-metadata --exif records the EXIF of a HEIC image", async () => {
const dir = join(root, "dump");
expect(
await backupMetadataCommand(
context(await livePhotoClient(HEIC_WITH_EXIF)),
dir,
{ exif: true },
),
).toBe(0);
const record = JSON.parse(
readFileSync(
join(dir, "collections", "1-Vacation", "300.json"),
"utf-8",
),
);
expect(record.imageMetadata.exifError).toBeUndefined();
expect(record.imageMetadata.exif).toMatchObject({
Make: { value: ["Canon"] },
Model: { value: ["EOS R5"] },
DateTimeOriginal: { value: ["2021:07:15 14:30:00"] },
});
});
});
describe("backup", () => {
@@ -695,7 +667,6 @@ describe("backup", () => {
" Failed: 0\n",
);
expect(stdout.text).toBe("");
expect(existsSync(join(dir, "backup.lock"))).toBe(false);
});
// The backup opens its library with the precache off: it fetches the
@@ -735,83 +706,15 @@ describe("backup", () => {
expect(stderr.text).toBe("Starting backup...\n");
});
it("--verify downloads again an original that does not match its hash, prints the counts, and exits 0", async () => {
// Each file records the hash of the original the fake writes for it.
it("exits 1 with the error on one line when the refresh fails", async () => {
const client = {
...fakeClient(),
filesSince: async (args: { collectionID: number }) => ({
files: (FILES[args.collectionID] ?? []).map((f) => ({
...f,
metadata: {
...f.metadata,
hash: blake2b(Buffer.alloc(7, f.id & 0xff)),
},
})),
deleted: [],
cursor: 1,
}),
} as unknown as Client;
const ctx = context(client);
const dir = join(root, "backup");
expect(await backupCommand(ctx, dir, {})).toBe(0);
writeFileSync(savePath(dir, FILES[1]![0]!), "corrupt");
expect(await backupCommand(ctx, dir, { verify: true })).toBe(0);
expect(stderr.text).toContain(
"MISMATCH original beach.jpg (100): its bytes do not match its content hash\n",
);
expect(stderr.text).toContain(
" Downloaded: 1\n" +
" Skipped: 2\n" +
" Verified: 2\n" +
" Mismatched: 1\n" +
" Unchecked: 0\n" +
" Failed: 0\n",
);
});
it("--verify --json adds the verified, mismatched and unchecked counts", async () => {
const dir = join(root, "backup");
expect(await backupCommand(context(), dir, {})).toBe(0);
const code = await backupCommand(context(), dir, {
verify: true,
json: true,
});
expect(code).toBe(0);
expect(JSON.parse(stdout.text)).toMatchObject({
skipped: 3,
verified: 0,
mismatched: 0,
unchecked: 3,
failed: 0,
});
});
it("exits 1 and lists each file when the ML data fetch fails", async () => {
const client = {
...fakeClient(),
fetchMLData: async () => {
throw new Error("HTTP 503 from server");
collectionsSince: async () => {
throw new Error("HTTP 401 from server");
},
} as unknown as Client;
expect(
await backupCommand(context(client), join(root, "backup"), {}),
).toBe(1);
expect(stderr.text).toContain(" Failed: 3\n");
expect(stderr.text).toContain(
" [Vacation] beach.jpg (id 100): ML data: HTTP 503 from server\n",
);
});
// 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 dir = join(root, "backup");
// Through `run`, as `bin/quak.ts` does, which prints a thrown error.
const runStderr = new PassThrough();
let runText = "";
runStderr.on("data", (chunk: Buffer) => {
@@ -819,230 +722,16 @@ describe("backup", () => {
});
const code = await new Promise<number>((resolve) => {
void run(
backupCommand(ctx, dir, {}),
backupCommand(context(client), dir, {}),
new PassThrough(),
runStderr,
resolve,
);
});
return { code, runText };
};
it("exits 1 with the error on one line when the refresh fails", async () => {
const client = {
...fakeClient(),
collectionsSince: async () => {
throw new Error("HTTP 503 from server");
},
} as unknown as Client;
const dir = join(root, "backup");
const { code, runText } = await backupThroughRun(context(client), dir);
expect(code).toBe(1);
expect(runText).toBe("quak: HTTP 503 from server\n");
expect(runText).toBe("quak: HTTP 401 from server\n");
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
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);
});
// A backup runs for as long as its downloads take, hours on a large
// account. It lists its files from the refresh it waits for as it starts,
// so it must not ask the server for changes again on a timer while it
// runs: each refresh is a request, and each also starts a pass that asks
// again for the ML data of every file Ente holds none for.
//
// A saved session is read back by `loadSession`, its `fetch` a fake of the
// Ente HTTP API serving one album with one photo, encrypted as the server
// serves them. The fake holds the photo's download open while the clock
// moves on by two hours, then serves it. Only `setTimeout` and
// `clearTimeout` are faked: the library's refresh timer runs on them.
it("asks the server for changes only as it starts, however long it runs", async () => {
const masterKey = new Uint8Array(32).fill(1);
const albumKey = new Uint8Array(32).fill(2);
const photoKey = new Uint8Array(32).fill(3);
saveSession(join(root, "session"), {
email: "cli@example.com",
userID: USER_ID,
token: "token",
masterKey: toBase64(masterKey),
secretKey: toBase64(new Uint8Array(32)),
publicKey: toBase64(new Uint8Array(32)),
});
// A secretbox as the server sends one: ciphertext and nonce, base64.
const box = (plaintext: Uint8Array, key: Uint8Array) => {
const nonce = sodium.randombytes_buf(
sodium.crypto_secretbox_NONCEBYTES,
);
return {
ciphertext: toBase64(
sodium.crypto_secretbox_easy(plaintext, nonce, key),
),
nonce: toBase64(nonce),
};
};
const albumKeyBox = box(albumKey, masterKey);
const albumName = box(new TextEncoder().encode("Vacation"), albumKey);
const photoKeyBox = box(photoKey, albumKey);
const metadata = encryptBlob(
new TextEncoder().encode(
JSON.stringify({
title: "beach.jpg",
fileType: 0,
creationTime: 1_700_000_000_000_000,
modificationTime: 1_700_000_000_000_000,
}),
),
photoKey,
);
const content = encryptBlob(
new TextEncoder().encode("the photo's bytes"),
photoKey,
);
const album = {
id: 1,
owner: { id: USER_ID },
encryptedKey: albumKeyBox.ciphertext,
keyDecryptionNonce: albumKeyBox.nonce,
encryptedName: albumName.ciphertext,
nameDecryptionNonce: albumName.nonce,
type: "album",
updationTime: 1,
};
const photo = {
id: 100,
collectionID: 1,
ownerID: USER_ID,
encryptedKey: photoKeyBox.ciphertext,
keyDecryptionNonce: photoKeyBox.nonce,
metadata: {
encryptedData: toBase64(metadata.ciphertext),
decryptionHeader: toBase64(metadata.header),
},
file: { decryptionHeader: toBase64(content.header) },
// The backup fetches no thumbnail.
thumbnail: { decryptionHeader: "" },
updationTime: 1,
};
// Each refresh starts with `collectionsSince`, which sends one
// `/collections/v2` request.
let refreshes = 0;
let downloadRequested!: () => void;
const requested = new Promise<void>((resolve) => {
downloadRequested = resolve;
});
let releaseDownload!: () => void;
const released = new Promise<void>((resolve) => {
releaseDownload = resolve;
});
const json = (body: unknown): Response =>
new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
});
const fetch = async (url: string | URL): Promise<Response> => {
const { hostname, pathname } = new URL(String(url));
if (hostname === "files.ente.io") {
downloadRequested();
await released;
return new Response(content.ciphertext);
}
if (pathname === "/collections/v2") {
refreshes++;
return json({ collections: [album] });
}
if (pathname === "/collections/v2/diff") {
return json({ diff: [photo], hasMore: false });
}
// Ente holds no ML data for the photo.
if (pathname === "/files/data/fetch") return json({ data: [] });
return new Response("not found", { status: 404 });
};
const ctx = {
...context(),
// The download's idle deadline is set past the two hours, or quak
// would give up the held download as stalled.
loadSession: (path: string) =>
loadSession(path, {
fetch: fetch as typeof globalThis.fetch,
downloadTimeoutMs: 3 * 3_600_000,
}),
};
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
const backup = backupCommand(ctx, join(root, "backup"), {});
await requested;
vi.advanceTimersByTime(2 * 3_600_000);
releaseDownload();
expect(await backup).toBe(0);
} finally {
vi.useRealTimers();
}
// Two refreshes, both before the download: the library opens onto the
// empty cache with one, and the backup waits for the other before it
// lists its files.
expect(refreshes).toBe(2);
expect(existsSync(join(dir, "originals"))).toBe(false);
});
});
@@ -1165,130 +854,3 @@ describe("backup-metadata and the thumbnail helpers refresh first", () => {
expect(stdout.text).toBe("");
});
});
// `files --collection`, `get`, `get-thumb` and `helper fix-missing-thumbnails
// --file` take an ID only when it is written in decimal digits and is a safe
// integer. `Number()` alone reads `0x10` as 16, `1e3` as 1000 and an empty
// string as 0, so the command would act on a file the user never named.
// Anything else is refused with one line naming it, and exit 1, before any
// request.
//
// These run on a real client, read back from a saved session, over a fake Ente
// server holding an empty account, which records each request it receives. With
// a good ID the command goes on to ask the server, and finds nothing there.
describe("an ID given on the command line", () => {
const NOT_IDS = [
"0x10",
"1e3",
"1.5",
"-1",
"",
"abc",
// One past Number.MAX_SAFE_INTEGER: from here on a number cannot
// hold every integer, so two IDs could read as one.
"9007199254740992",
];
const requests: Request[] = [];
const emptyAccount = (): CliContext => {
const ctx = context();
const key = toBase64(new Uint8Array(32));
saveSession(ctx.sessionDir, {
email: "cli@example.com",
userID: USER_ID,
token: "token",
masterKey: key,
secretKey: key,
publicKey: key,
});
return {
...ctx,
loadSession: (path) =>
loadSession(path, {
fetch: async (url, init) => {
requests.push(new Request(url, init));
return new Response(
JSON.stringify({ collections: [] }),
{ headers: { "content-type": "application/json" } },
);
},
}),
};
};
beforeEach(() => {
requests.length = 0;
});
it("files looks up a decimal collection ID", async () => {
const ctx = emptyAccount();
expect(await filesCommand(ctx, { collection: "1" })).toBe(1);
expect(stderr.text).toBe("Collection 1 not found\n");
expect(requests).not.toHaveLength(0);
});
it.each(NOT_IDS)(
"files refuses --collection %j before any request",
async (arg) => {
const ctx = emptyAccount();
expect(await filesCommand(ctx, { collection: arg })).toBe(1);
expect(stderr.text).toBe(`Invalid collection ID: ${arg}\n`);
expect(stdout.text).toBe("");
expect(requests).toHaveLength(0);
},
);
it("get looks up a decimal file ID", async () => {
expect(await getCommand(emptyAccount(), "100", {})).toBe(1);
expect(stderr.text).toBe("File 100 not found\n");
expect(requests).not.toHaveLength(0);
});
it.each(NOT_IDS)("get refuses %j before any request", async (arg) => {
expect(await getCommand(emptyAccount(), arg, {})).toBe(1);
expect(stderr.text).toBe(`Invalid file ID: ${arg}\n`);
expect(stdout.text).toBe("");
expect(requests).toHaveLength(0);
});
it("get-thumb looks up a decimal file ID", async () => {
expect(await getThumbCommand(emptyAccount(), "100", {})).toBe(1);
expect(stderr.text).toBe("File 100 not found\n");
expect(requests).not.toHaveLength(0);
});
it.each(NOT_IDS)("get-thumb refuses %j before any request", async (arg) => {
expect(await getThumbCommand(emptyAccount(), arg, {})).toBe(1);
expect(stderr.text).toBe(`Invalid file ID: ${arg}\n`);
expect(stdout.text).toBe("");
expect(requests).toHaveLength(0);
});
// The fixer reports each file it was given; on this account neither is
// found, which is a failure, so it exits 1.
it("fix-missing-thumbnails looks up each decimal --file ID", async () => {
const ctx = emptyAccount();
const opts = { file: ["100", "200"], json: true };
expect(await fixMissingThumbnailsCommand(ctx, opts)).toBe(1);
expect(JSON.parse(stdout.text)).toMatchObject([
{ fileID: 100, reason: "file not found in any collection" },
{ fileID: 200, reason: "file not found in any collection" },
]);
expect(requests).not.toHaveLength(0);
});
// One bad ID fails the whole command, so the good one before it is not
// fixed either.
it.each(NOT_IDS)(
"fix-missing-thumbnails refuses --file 100 %j before any request",
async (arg) => {
const ctx = emptyAccount();
const opts = { file: ["100", arg] };
expect(await fixMissingThumbnailsCommand(ctx, opts)).toBe(1);
expect(stderr.text).toBe(`Invalid file ID: ${arg}\n`);
expect(stdout.text).toBe("");
expect(requests).toHaveLength(0);
},
);
});
+173 -14
View File
@@ -37,8 +37,15 @@ import {
import { join } from "node:path";
import { tmpdir } from "node:os";
import sodium from "libsodium-wrappers-sumo";
import { SRP, SrpServer } from "fast-srp-hap";
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { init, toBase64, encryptBlob } from "../../src/crypto/index.js";
import {
init,
toBase64,
deriveKEK,
deriveLoginSubkey,
encryptBlob,
} from "../../src/crypto/index.js";
import * as jpegJs from "jpeg-js";
import { Client } from "../../src/client.js";
import { Library } from "../../src/library/index.js";
@@ -47,13 +54,7 @@ import {
type MetadataBackupOptions,
} from "../../src/metadata-backup.js";
import { backupMetadataCommand } from "../../src/cli-commands.js";
import {
buildAccount,
buildMetaFetch,
encryptSecretbox,
encryptStreamBlob,
type MetaMockState,
} from "../ente-server.js";
import type { KeyAttributes } from "../../src/auth/types.js";
// One file per ML data request, so the two files of the mock account are
// fetched in two requests and one of them can fail on its own.
@@ -64,13 +65,80 @@ vi.mock("../../src/mldata-fetch.js", async (importOriginal) => ({
const TEST_EMAIL = "metabackup@example.com";
const TEST_PASSWORD = "metapass";
const TEST_OPS = 2;
const TEST_MEM = 64 * 1024 * 1024;
interface MetaMockState {
verifier: Buffer;
srpAttributes: Record<string, unknown>;
keyAttributes: KeyAttributes;
encryptedToken: string;
collections: Record<string, unknown>[];
filesByCollection: Record<number, Record<string, unknown>[]>;
// For ML data and EXIF tests
encryptedMLData: Record<
number,
{ encryptedData: string; decryptionHeader: string }
>;
fileCiphertexts: Record<number, Uint8Array>;
}
let mock: MetaMockState;
let testDir: string;
const encryptSecretbox = (plaintext: Uint8Array, key: Uint8Array) => {
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key);
return { ciphertext, nonce };
};
const encryptStreamBlob = (plaintext: Uint8Array, key: Uint8Array) => {
const push = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
const ciphertext = sodium.crypto_secretstream_xchacha20poly1305_push(
push.state,
plaintext,
null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
);
return { ciphertext, header: push.header };
};
const buildMetaMock = async (): Promise<MetaMockState> => {
const account = await buildAccount(TEST_PASSWORD);
const masterKey = account.masterKey;
const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
const kek = await deriveKEK(TEST_PASSWORD, kekSalt, TEST_OPS, TEST_MEM);
const loginSubKeyBytes = deriveLoginSubkey(kek);
const srpUserID = "meta-srp";
const srpSalt = sodium.randombytes_buf(16);
const verifier = SRP.computeVerifier(
SRP.params["4096"],
Buffer.from(srpSalt),
Buffer.from(srpUserID),
Buffer.from(loginSubKeyBytes),
);
const masterKey = sodium.randombytes_buf(32);
const { ciphertext: encMK, nonce: mkNonce } = encryptSecretbox(
masterKey,
kek,
);
const kp = sodium.crypto_box_keypair();
const { ciphertext: encSK, nonce: skNonce } = encryptSecretbox(
kp.privateKey,
masterKey,
);
const tokenBytes = sodium.randombytes_buf(32);
const encToken = sodium.crypto_box_seal(tokenBytes, kp.publicKey);
const keyAttributes: KeyAttributes = {
kekSalt: toBase64(kekSalt),
encryptedKey: toBase64(encMK),
keyDecryptionNonce: toBase64(mkNonce),
publicKey: toBase64(kp.publicKey),
encryptedSecretKey: toBase64(encSK),
secretKeyDecryptionNonce: toBase64(skNonce),
memLimit: TEST_MEM,
opsLimit: TEST_OPS,
};
// Collection 1: "Vacation" with collection-level pubMagicMetadata
const ck1 = sodium.crypto_secretbox_keygen();
@@ -264,10 +332,17 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
rawFile1.file.decryptionHeader = toBase64(filePush1.header);
return {
verifier: account.verifier,
srpAttributes: account.srpAttributes,
keyAttributes: account.keyAttributes,
encryptedToken: account.encryptedToken,
verifier,
srpAttributes: {
srpUserID,
srpSalt: toBase64(srpSalt),
memLimit: TEST_MEM,
opsLimit: TEST_OPS,
kekSalt: toBase64(kekSalt),
isEmailMFAEnabled: false,
},
keyAttributes,
encryptedToken: toBase64(encToken),
collections: [rawColl1, rawColl2],
filesByCollection: { 10: [rawFile1], 20: [rawFile2] },
encryptedMLData: {
@@ -280,6 +355,90 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
};
};
// `failMLDataFor`: answer 500 to every ML data request that asks for this file.
const buildMetaFetch = (m: MetaMockState, failMLDataFor?: number) => {
let srpServer: SrpServer;
return (async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const path = new URL(url).pathname;
const json = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
if (path === "/users/srp/attributes")
return json({ attributes: m.srpAttributes });
if (path === "/users/srp/create-session") {
const body = JSON.parse(init?.body as string);
const serverKey = await SRP.genKey();
srpServer = new SrpServer(
SRP.params["4096"],
m.verifier,
serverKey,
);
const B = srpServer.computeB();
srpServer.setA(Buffer.from(body.srpA, "base64"));
return json({ sessionID: "s1", srpB: B.toString("base64") });
}
if (path === "/users/srp/verify-session") {
const body = JSON.parse(init?.body as string);
srpServer.checkM1(Buffer.from(body.srpM1, "base64"));
return json({
srpM2: srpServer.computeM2().toString("base64"),
id: 42,
keyAttributes: m.keyAttributes,
encryptedToken: m.encryptedToken,
});
}
if (path === "/collections/v2")
return json({ collections: m.collections });
if (path === "/collections/v2/diff") {
const collID = Number(
new URL(url).searchParams.get("collectionID"),
);
return json({
diff: m.filesByCollection[collID] ?? [],
hasMore: false,
});
}
if (path === "/files/data/fetch") {
const body = JSON.parse(init?.body as string);
if ((body.fileIDs as number[]).includes(failMLDataFor!))
return new Response("server error", { status: 500 });
const data = (body.fileIDs as number[])
.filter((id: number) => m.encryptedMLData[id])
.map((id: number) => ({
fileID: id,
...m.encryptedMLData[id],
updatedAt: 1700000000000000,
}));
return json({ data });
}
if (
url.includes("files.ente.io") ||
path.startsWith("/files/download/")
) {
const parsed = new URL(url);
const fileID = Number(
parsed.searchParams.get("fileID") ?? path.split("/").pop(),
);
const ct = m.fileCiphertexts[fileID];
if (ct) return new Response(ct, { status: 200 });
return new Response("not found", { status: 404 });
}
return new Response("not found", { status: 404 });
}) as typeof globalThis.fetch;
};
beforeAll(async () => {
await init();
await sodium.ready;
+69 -301
View File
@@ -1,28 +1,21 @@
/**
* Tests for reading EXIF (`src/exif.ts`) and the image metadata
* `quak backup-metadata --exif` records.
* Tests for the JPEG EXIF scan behind `quak backup-metadata --exif`.
*
* The originals come from users' libraries, so a truncated or corrupt file
* must neither hang the read nor throw out of it: `readAllExifTags` gives `{}`,
* and `backup-metadata` tells an EXIF block it cannot read apart from a file
* that simply has no EXIF, carrying the reason in `exifError`. Each JPEG below
* is a short hand-built byte array; the HEIC is a real file.
* The originals come from users' libraries, so a truncated or corrupt JPEG
* must neither hang the scan nor throw out of it, and a malformed file must be
* told apart from one that simply has no EXIF: the record carries the reason in
* `exifError`. Each input below is a short hand-built byte array.
*/
import { describe, expect, it } from "vitest";
import {
readAllExifTags,
readExifTags,
readPhotoExif,
} from "../../src/exif.js";
import { extractImageMetadata } from "../../src/metadata-backup.js";
import { HEIC_WITH_EXIF } from "../exif-heic.js";
extractExifFromJpeg,
extractImageMetadata,
} from "../../src/metadata-backup.js";
const SOI = [0xff, 0xd8]; // start of image
const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan
const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan, where the scan stops
const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0"
const APP0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
const ZERO_LENGTH_APP0 = [0xff, 0xe0, 0x00, 0x00];
// A big-endian TIFF block with one IFD entry: Orientation (0x0112), SHORT, 6.
const TIFF_ORIENTATION_6 = [
@@ -31,132 +24,6 @@ const TIFF_ORIENTATION_6 = [
0x00, 0x00,
];
// A big-endian TIFF block holding Orientation 6 and DateTimeOriginal
// "0000:00:00 00:00:00", which a camera with an unset clock writes.
const TIFF_UNSET_DATE = [
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
// The first IFD, at 8: two entries, then no next IFD.
...[0x00, 0x02],
// Orientation (0x0112), SHORT, 6.
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00],
// The Exif IFD's offset (0x8769), LONG, 38.
...[0x87, 0x69, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x26],
...[0x00, 0x00, 0x00, 0x00],
// The Exif IFD, at 38: one entry, then no next IFD.
...[0x00, 0x01],
// DateTimeOriginal (0x9003), 20 ASCII bytes at 56.
...[0x90, 0x03, 0x00, 0x02, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x38],
...[0x00, 0x00, 0x00, 0x00],
...new TextEncoder().encode("0000:00:00 00:00:00\0"), // at 56
];
// A big-endian TIFF block holding a GPSAltitude of 12.5 m and no
// GPSAltitudeRef.
const TIFF_ALTITUDE_WITHOUT_REF = [
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
// The first IFD, at 8: one entry, then no next IFD.
...[0x00, 0x01],
// The GPS IFD's offset (0x8825), LONG, 26.
...[0x88, 0x25, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1a],
...[0x00, 0x00, 0x00, 0x00],
// The GPS IFD, at 26: one entry, then no next IFD.
...[0x00, 0x01],
// GPSAltitude (0x0006), one RATIONAL at 44.
...[0x00, 0x06, 0x00, 0x05, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x2c],
...[0x00, 0x00, 0x00, 0x00],
...[0x00, 0x00, 0x00, 0x19, 0x00, 0x00, 0x00, 0x02], // 25/2, at 44
];
// A big-endian TIFF block holding a GPSLatitude of 33° 30' 0" and a
// GPSLatitudeRef of "S".
const TIFF_SOUTHERN_LATITUDE = [
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
// The first IFD, at 8: one entry, then no next IFD.
...[0x00, 0x01],
// The GPS IFD's offset (0x8825), LONG, 26.
...[0x88, 0x25, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1a],
...[0x00, 0x00, 0x00, 0x00],
// The GPS IFD, at 26: two entries, then no next IFD.
...[0x00, 0x02],
// GPSLatitudeRef (0x0001), 2 ASCII bytes: "S".
...[0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x53, 0x00, 0x00, 0x00],
// GPSLatitude (0x0002), three RATIONALs at 56.
...[0x00, 0x02, 0x00, 0x05, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x38],
...[0x00, 0x00, 0x00, 0x00],
...[0x00, 0x00, 0x00, 0x21, 0x00, 0x00, 0x00, 0x01], // 33/1, at 56
...[0x00, 0x00, 0x00, 0x1e, 0x00, 0x00, 0x00, 0x01], // 30/1
...[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01], // 0/1
];
// A big-endian TIFF block holding a GPSLatitude of 40° 26' 46" and a
// GPSLongitude of 79° 58' 56", and neither GPSLatitudeRef nor GPSLongitudeRef.
const TIFF_POSITION_WITHOUT_REFS = [
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
// The first IFD, at 8: one entry, then no next IFD.
...[0x00, 0x01],
// The GPS IFD's offset (0x8825), LONG, 26.
...[0x88, 0x25, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1a],
...[0x00, 0x00, 0x00, 0x00],
// The GPS IFD, at 26: two entries, then no next IFD.
...[0x00, 0x02],
// GPSLatitude (0x0002), three RATIONALs at 56.
...[0x00, 0x02, 0x00, 0x05, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x38],
// GPSLongitude (0x0004), three RATIONALs at 80.
...[0x00, 0x04, 0x00, 0x05, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x50],
...[0x00, 0x00, 0x00, 0x00],
...[0x00, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00, 0x01], // 40/1, at 56
...[0x00, 0x00, 0x00, 0x1a, 0x00, 0x00, 0x00, 0x01], // 26/1
...[0x00, 0x00, 0x00, 0x2e, 0x00, 0x00, 0x00, 0x01], // 46/1
...[0x00, 0x00, 0x00, 0x4f, 0x00, 0x00, 0x00, 0x01], // 79/1, at 80
...[0x00, 0x00, 0x00, 0x3a, 0x00, 0x00, 0x00, 0x01], // 58/1
...[0x00, 0x00, 0x00, 0x38, 0x00, 0x00, 0x00, 0x01], // 56/1
];
// A big-endian TIFF block holding Orientation 6 and a Make whose value lies
// past the end of the file.
const TIFF_MAKE_PAST_END = [
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
// The first IFD, at 8: two entries, then no next IFD.
...[0x00, 0x02],
// Make (0x010f), 6 ASCII bytes at 4096.
...[0x01, 0x0f, 0x00, 0x02, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x10, 0x00],
// Orientation (0x0112), SHORT, 6.
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00],
...[0x00, 0x00, 0x00, 0x00],
];
// A big-endian TIFF block with one IFD entry that exifreader has no name for:
// tag 0xc000 (49152), SHORT, 7.
const TIFF_UNNAMED_TAG = [
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
// The first IFD, at 8: one entry, then no next IFD.
...[0x00, 0x01],
// Tag 0xc000, SHORT, 7.
...[0xc0, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x07, 0x00, 0x00],
...[0x00, 0x00, 0x00, 0x00],
];
// A big-endian TIFF block holding Orientation 6, and a thumbnail IFD holding
// its own Orientation 1 and a 4-byte JPEG thumbnail.
const TIFF_WITH_THUMBNAIL = [
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
// The first IFD, at 8: one entry, then the thumbnail IFD at 26.
...[0x00, 0x01],
// Orientation (0x0112), SHORT, 6.
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00],
...[0x00, 0x00, 0x00, 0x1a],
// The thumbnail IFD, at 26: three entries, then no next IFD.
...[0x00, 0x03],
// Orientation (0x0112), SHORT, 1.
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00],
// JPEGInterchangeFormat (0x0201), LONG: the thumbnail is at 68.
...[0x02, 0x01, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x44],
// JPEGInterchangeFormatLength (0x0202), LONG: 4 bytes.
...[0x02, 0x02, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04],
...[0x00, 0x00, 0x00, 0x00],
...[0xff, 0xd8, 0xff, 0xd9], // the thumbnail, at 68: an empty JPEG
];
// An APP1 segment whose length field matches its data.
const app1 = (data: number[]): number[] => {
const len = data.length + 2;
@@ -166,147 +33,74 @@ const app1 = (data: number[]): number[] => {
const bytes = (...parts: number[][]): Uint8Array =>
new Uint8Array(parts.flat());
describe("readAllExifTags", () => {
it("keys a tag exifreader has no name for by its number", () => {
const data = [...EXIF_HEADER, ...TIFF_UNNAMED_TAG];
expect(readAllExifTags(bytes(SOI, app1(data), SOS))).toStrictEqual({
"undefined-49152": {
id: 49152,
value: 7,
description: 7,
computed: 7,
},
});
});
it("puts the thumbnail's tags under Thumbnail, without its image", () => {
const input = bytes(
SOI,
app1([...EXIF_HEADER, ...TIFF_WITH_THUMBNAIL]),
SOS,
);
// exifreader finds the thumbnail's image.
expect(readExifTags(input)?.Thumbnail?.type).toBe("image/jpeg");
const tags = readAllExifTags(input);
expect(tags.Orientation?.value).toBe(6);
expect(Object.keys(tags.Thumbnail ?? {}).sort()).toEqual([
"JPEGInterchangeFormat",
"JPEGInterchangeFormatLength",
"Orientation",
]);
expect(tags.Thumbnail?.Orientation?.value).toBe(1);
expect(readPhotoExif(tags)).toStrictEqual({ orientation: 6 });
});
});
describe("readPhotoExif", () => {
it("reads the common fields of a valid JPEG", () => {
describe("extractExifFromJpeg", () => {
it("returns the EXIF segment of a valid JPEG", () => {
const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6];
expect(
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))),
).toStrictEqual({
orientation: 6,
});
const scan = extractExifFromJpeg(bytes(SOI, app1(data), SOS));
expect(scan.error).toBeUndefined();
expect([...scan.exif!]).toEqual(data);
});
it("gives no dateTimeOriginal for a DateTimeOriginal of 0000:00:00 00:00:00", () => {
const data = [...EXIF_HEADER, ...TIFF_UNSET_DATE];
expect(
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))),
).toStrictEqual({
orientation: 6,
});
it("returns nothing for a file that is not a JPEG", () => {
const png = bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
expect(extractExifFromJpeg(png)).toEqual({});
});
it("reads a GPSAltitude without GPSAltitudeRef as above sea level", () => {
const data = [...EXIF_HEADER, ...TIFF_ALTITUDE_WITHOUT_REF];
expect(
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))),
).toStrictEqual({
gpsAltitude: 12.5,
});
it("returns nothing for a JPEG without EXIF", () => {
const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
expect(extractExifFromJpeg(bytes(SOI, app0, SOS))).toEqual({});
});
it("reads a GPSLatitude with GPSLatitudeRef S as south of the equator", () => {
const data = [...EXIF_HEADER, ...TIFF_SOUTHERN_LATITUDE];
expect(
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))),
).toStrictEqual({
gpsLatitude: -33.5,
});
it("ignores an APP1 segment too short to hold the Exif header", () => {
// A length under 8 cannot hold the six-byte "Exif\0\0" header, so the
// segment is not EXIF. This one has length 7 and holds only "Exif\0",
// which the old code, lacking the length check, returned as EXIF.
const short = app1(EXIF_HEADER.slice(0, 5));
expect(extractExifFromJpeg(bytes(SOI, short, SOS))).toEqual({});
});
it("gives no gpsLatitude or gpsLongitude without their reference tags", () => {
const data = [...EXIF_HEADER, ...TIFF_POSITION_WITHOUT_REFS];
const tags = readAllExifTags(bytes(SOI, app1(data), SOS));
// The position is read; only its hemisphere is unknown.
expect(tags.GPSLatitude?.computed).toStrictEqual([40, 26, 46]);
expect(tags.GPSLongitude?.computed).toStrictEqual([79, 58, 56]);
expect(readPhotoExif(tags)).toStrictEqual({});
it("accepts an APP1 segment of length 8 holding just the Exif header", () => {
const scan = extractExifFromJpeg(bytes(SOI, app1(EXIF_HEADER), SOS));
expect(scan.error).toBeUndefined();
expect([...scan.exif!]).toEqual(EXIF_HEADER);
});
it("gives no make for a Make whose value lies past the end of the file", () => {
const data = [...EXIF_HEADER, ...TIFF_MAKE_PAST_END];
expect(
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))),
).toStrictEqual({
orientation: 6,
});
it("reports a JPEG truncated inside a segment header", () => {
const scan = extractExifFromJpeg(bytes(SOI, [0xff, 0xe1, 0x00]));
expect(scan.exif).toBeUndefined();
expect(scan.error).toMatch(/truncated segment length/);
});
it.each([
[
"a file that is not an image",
new TextEncoder().encode("just some text, not an image"),
],
[
"a PNG without EXIF",
bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
],
["a JPEG without EXIF", bytes(SOI, APP0, SOS)],
// A length under 8 cannot hold the six-byte "Exif\0\0" header. This one
// has length 7 and holds only "Exif\0", so a read past its end would
// take the next segment's bytes as EXIF.
[
"an APP1 segment too short to hold the Exif header",
bytes(SOI, app1(EXIF_HEADER.slice(0, 5)), SOS),
],
[
"an APP1 segment holding just the Exif header",
bytes(SOI, app1(EXIF_HEADER), SOS),
],
[
"a JPEG truncated inside a segment header",
bytes(SOI, [0xff, 0xe1, 0x00]),
],
["a JPEG that ends before the image data", bytes(SOI, APP0)],
// A length of 0 would step a scan by 2 bytes at a time through the
// rest of the file, reading garbage as markers.
[
"a zero-length segment",
bytes(
SOI,
ZERO_LENGTH_APP0,
ZERO_LENGTH_APP0,
ZERO_LENGTH_APP0,
ZERO_LENGTH_APP0,
SOS,
),
],
["a segment length of 1", bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS)],
it("reports a JPEG that ends before the image data", () => {
const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
const scan = extractExifFromJpeg(bytes(SOI, app0));
expect(scan.error).toMatch(/ends before the image data/);
});
it("stops on a zero-length segment instead of looping", () => {
// A length of 0 would otherwise step the scan by 2 bytes at a time
// through the rest of the file, reading garbage as markers.
const zero = [0xff, 0xe0, 0x00, 0x00];
const scan = extractExifFromJpeg(
bytes(SOI, zero, zero, zero, zero, SOS),
);
expect(scan.error).toMatch(/segment length 0 at byte 2 is too small/);
});
it("stops on a segment length of 1", () => {
const scan = extractExifFromJpeg(
bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS),
);
expect(scan.error).toMatch(/segment length 1 at byte 2 is too small/);
});
it("reports a segment length that runs past the end of the file", () => {
// APP1 claims 0x4000 bytes but only the "Exif\0\0" header follows.
[
"a segment length that runs past the end of the file",
const scan = extractExifFromJpeg(
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
],
// "XX" where the TIFF byte order belongs.
[
"an EXIF block that cannot be parsed",
bytes(SOI, app1([...EXIF_HEADER, 0x58, 0x58]), SOS),
],
])("returns no tags and no fields for %s", (_, input) => {
expect(readAllExifTags(input)).toStrictEqual({});
expect(readPhotoExif(readAllExifTags(input))).toStrictEqual({});
);
expect(scan.exif).toBeUndefined();
expect(scan.error).toMatch(/runs past the end of the file/);
});
});
@@ -316,30 +110,10 @@ describe("extractImageMetadata", () => {
bytes(SOI, app1([...EXIF_HEADER, ...TIFF_ORIENTATION_6]), SOS),
);
expect(meta?.exifError).toBeUndefined();
expect(meta?.exif).toMatchObject({ Orientation: { value: 6 } });
expect(meta?.exif).toMatchObject({ Image: { Orientation: 6 } });
});
it("parses EXIF from a HEIC", () => {
const meta = extractImageMetadata(HEIC_WITH_EXIF);
expect(meta?.exifError).toBeUndefined();
expect(meta?.exif).toMatchObject({
Make: { value: ["Canon"] },
Model: { value: ["EOS R5"] },
DateTimeOriginal: { value: ["2021:07:15 14:30:00"] },
Orientation: { value: 6 },
GPSLatitudeRef: { value: ["N"] },
});
});
it("keys a tag exifreader has no name for by its number", () => {
const meta = extractImageMetadata(
bytes(SOI, app1([...EXIF_HEADER, ...TIFF_UNNAMED_TAG]), SOS),
);
expect(meta?.exifError).toBeUndefined();
expect(meta?.exif).toMatchObject({ "undefined-49152": { value: 7 } });
});
it("returns nothing for a file that is not an image", () => {
it("returns nothing for a file that is not a JPEG", () => {
const text = new TextEncoder().encode("just some text, not an image");
expect(extractImageMetadata(text)).toBeUndefined();
});
@@ -349,20 +123,14 @@ describe("extractImageMetadata", () => {
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
);
expect(meta?.exif).toBeUndefined();
expect(meta?.exifError).toBe(
"no tag could be read from the EXIF block",
);
expect(meta?.exifError).toMatch(/runs past the end of the file/);
});
it("keeps the raw bytes and the reason when EXIF cannot be parsed", () => {
// The raw bytes are the whole EXIF block as exifreader finds it: for a
// JPEG, the APP1 segment, marker and length included.
const segment = app1([...EXIF_HEADER, 0x58, 0x58]);
const meta = extractImageMetadata(bytes(SOI, segment, SOS));
const data = [...EXIF_HEADER, 0x58, 0x58];
const meta = extractImageMetadata(bytes(SOI, app1(data), SOS));
expect(meta?.exif).toBeUndefined();
expect(meta?.exifRaw).toBe(Buffer.from(segment).toString("base64"));
expect(meta?.exifError).toBe(
"no tag could be read from the EXIF block",
);
expect(meta?.exifRaw).toBe(Buffer.from(data).toString("base64"));
expect(meta?.exifError).toEqual(expect.any(String));
});
});
-23
View File
@@ -5,7 +5,6 @@
import { PassThrough } from "node:stream";
import { describe, it, expect } from "vitest";
import { ApiError } from "../../src/api/client.js";
import { run } from "../../src/cli-run.js";
// A stream whose written text is kept in `text`; writes finish at once, so
@@ -56,26 +55,4 @@ describe("run", () => {
stderr: "quak: offline\n",
});
});
it("on a 401 from the server says to run quak login, on one line, and exits 3", async () => {
const result = await runToExit(
Promise.reject(new ApiError("unauthorized", 401)),
);
expect(result).toEqual({
code: 3,
stdout: "",
stderr: `quak: the saved session is no longer valid; run "quak login"\n`,
});
});
it("prints another HTTP error as it is and exits 1", async () => {
const result = await runToExit(
Promise.reject(new ApiError("forbidden", 403)),
);
expect(result).toEqual({
code: 1,
stdout: "",
stderr: "quak: forbidden\n",
});
});
});
+10 -126
View File
@@ -10,9 +10,7 @@
* The return shape keeps live records and tombstones apart — `collections` /
* `files` are decrypted live records, `deleted` is a plain list of the ids the
* server tombstoned. A tombstone carries no decryptable key or metadata, so it
* is a bare id rather than a hollowed-out `Collection` / `EnteFile`. 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.
* is a bare id rather than a hollowed-out `Collection` / `EnteFile`.
*
* All tests inject a fake `fetch` and drive a real `Client` (built with
* `Client.fromJSON`) so the decryption path runs for real. Live rows are built
@@ -93,38 +91,27 @@ const ownedCollectionRow = (
};
};
/**
* 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`.
*/
/** A live file row inside a collection, keyed under that collection's key. */
const fileRow = (
collectionKey: Uint8Array,
opts: {
id: number;
title: string;
updationTime: number;
metadata?: string;
},
opts: { id: number; title: string; updationTime: number },
): Record<string, unknown> => {
const fileKey = sodium.crypto_secretbox_keygen();
const { ciphertext: encFileKey, nonce: fileKeyNonce } = secretboxEncrypt(
fileKey,
collectionKey,
);
const metadata =
opts.metadata ??
JSON.stringify({
title: opts.title,
fileType: 0,
creationTime: opts.updationTime,
modificationTime: opts.updationTime,
});
const metadata = {
title: opts.title,
fileType: 0,
creationTime: opts.updationTime,
modificationTime: opts.updationTime,
};
const push =
sodium.crypto_secretstream_xchacha20poly1305_init_push(fileKey);
const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push(
push.state,
new TextEncoder().encode(metadata),
new TextEncoder().encode(JSON.stringify(metadata)),
null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
);
@@ -144,21 +131,6 @@ const fileRow = (
};
};
/**
* `row` with its sealed key replaced by random bytes of the same length, as a
* corrupt row arrives: no key opens it, so the row cannot be decrypted.
*/
const withCorruptKey = (
row: Record<string, unknown>,
): Record<string, unknown> => ({
...row,
encryptedKey: toBase64(
sodium.randombytes_buf(
sodium.crypto_secretbox_KEYBYTES + sodium.crypto_secretbox_MACBYTES,
),
),
});
/** A tombstone row. Never decrypted, so only these fields are ever read. */
const tombstoneRow = (
id: number,
@@ -306,58 +278,6 @@ describe("Client.filesSince", () => {
// It gave up after the first page rather than looping.
expect(sinceTimes).toEqual(["50"]);
});
it("leaves out each file it cannot decrypt or parse, with the reason, and decrypts the rest", async () => {
const keys = buildKeys();
const collectionKey = sodium.crypto_secretbox_keygen();
// File 1002's key is sealed under no key quak holds, and file 1003's
// metadata decrypts to text that is not JSON. Neither may cost the
// caller file 1001: each is left out of `files` and reported under
// `undecryptable` with the reason instead of failing the call.
const { fetch } = recordingFetch(
jsonResponse({
diff: [
fileRow(collectionKey, {
id: 1001,
title: "fine.jpg",
updationTime: 100,
}),
withCorruptKey(
fileRow(collectionKey, {
id: 1002,
title: "bad-key.jpg",
updationTime: 200,
}),
),
fileRow(collectionKey, {
id: 1003,
title: "bad-metadata.jpg",
updationTime: 300,
metadata: "not json",
}),
],
hasMore: false,
}),
);
const client = Client.fromJSON(snapshotFor(keys), { fetch });
const { files, undecryptable, cursor } = await client.filesSince({
collectionID: 1,
collectionKey,
sinceTime: 0,
});
expect(files.map((f) => f.id)).toEqual([1001]);
expect(undecryptable).toEqual([
{ id: 1002, error: expect.stringContaining("wrong secret key") },
{ id: 1003, error: expect.stringContaining("JSON") },
]);
// The cursor moves past the two left out, as past any other row, so
// the server does not send them again until its copy changes. The
// caller has to keep what it was told here.
expect(cursor).toBe(300);
});
});
describe("Client.collectionsSince", () => {
@@ -411,42 +331,6 @@ describe("Client.collectionsSince", () => {
expect(result.cursor).toBe(150);
expect(sinceTimes).toEqual(["150"]);
});
it("leaves out an album it cannot decrypt, with the reason, and decrypts the rest", async () => {
const keys = buildKeys();
// Album 2's key is sealed under no key quak holds. Without its key
// neither its name nor its files can be read, so it is left out and
// reported under `undecryptable`; album 1 is unaffected.
const { fetch } = recordingFetch(
jsonResponse({
collections: [
ownedCollectionRow(keys.masterKey, {
id: 1,
name: "Vacation",
updationTime: 100,
}),
withCorruptKey(
ownedCollectionRow(keys.masterKey, {
id: 2,
name: "Broken",
updationTime: 200,
}),
),
],
}),
);
const client = Client.fromJSON(snapshotFor(keys), { fetch });
const { collections, undecryptable, cursor } =
await client.collectionsSince({ sinceTime: 0 });
expect(collections.map((c) => c.name)).toEqual(["Vacation"]);
expect(undecryptable).toEqual([
{ id: 2, error: expect.stringContaining("wrong secret key") },
]);
expect(cursor).toBe(200);
});
});
describe("Client list wrappers still hide deletions", () => {
-18
View File
@@ -12,10 +12,6 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { init, toBase64 } from "../../src/crypto/index.js";
import { Client, type ClientSnapshot } from "../../src/client.js";
import { loadSession } from "../../src/cli-session.js";
import {
DEFAULT_RETRY_OPTIONS,
UNATTENDED_RETRY_OPTIONS,
} from "../../src/retry.js";
const validSnapshot = (): ClientSnapshot => {
const kp = sodium.crypto_box_keypair();
@@ -180,20 +176,6 @@ describe("loadSession", () => {
});
});
it("gives the restored client the retry options it is passed", () => {
const path = join(dir, "retry.json");
writeFileSync(path, JSON.stringify(validSnapshot()));
const retryOptions = (client: Client | null) =>
client!.getApiClient().getRetryOptions();
expect(
retryOptions(
loadSession(path, { retry: UNATTENDED_RETRY_OPTIONS }),
),
).toEqual(UNATTENDED_RETRY_OPTIONS);
expect(retryOptions(loadSession(path))).toEqual(DEFAULT_RETRY_OPTIONS);
});
it("says the file is corrupt when it is not JSON", () => {
const path = join(dir, "truncated.json");
writeFileSync(path, '{"email": "user@exa');
+9
View File
@@ -1883,6 +1883,15 @@ describe("downloadFile live photos", () => {
expect(readdirSync(t.dir).sort()).toEqual(["f.JPG", "f.bin"]);
});
it("replaces what was at the destination, such as an earlier ZIP of the two", async () => {
const t = setup(livePhotoZip(), livePhoto);
writeFileSync(t.outPath, livePhotoZip());
await t.run();
expect(readdirSync(t.dir).sort()).toEqual(["f.heic", "f.mov"]);
});
it("renames the image and then the video into place, each from its own temp file", async () => {
const t = setup(livePhotoZip(), livePhoto);
-197
View File
@@ -1,197 +0,0 @@
/**
* 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;
};
-306
View File
@@ -1,306 +0,0 @@
/**
* The example script `examples/download-albums.ts` (issue #144), run twice
* against a stand-in account, as a user would run it twice.
*
* The account has two albums sharing one photo, and one of its photos is a
* live photo whose image is a HEIC with EXIF. The first run puts every
* original at its save path, writes each photo's metadata beside it and each
* album's photos under `albums/`. Before the second run the account gains an
* album holding a new photo. The second run downloads that photo and writes
* its files and the new album's, and fetches nothing else and changes no
* other file.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { downloadAlbums } from "../../examples/download-albums.js";
import { Library, type ContentSource } from "../../src/index.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
import { readAllExifTags } from "../../src/exif.js";
import { HEIC_WITH_EXIF } from "../exif-heic.js";
import {
asLivePhoto,
cdnSource,
livePhotoHash,
livePhotoZip,
VIDEO,
} from "../live-photo.js";
const USER_ID = 7;
// Every photo is taken at noon local time on 2026-03-01, so the machine's time
// zone cannot move it to another day; it is saved in the folder `DAY`. Ente
// stores times in microseconds.
const TAKEN_MS = new Date(2026, 2, 1, 12).getTime();
const DAY = join("2026", "2026-03", "2026-03-01");
const collection = (id: number, name: string): Collection => ({
id,
ownerID: USER_ID,
key: new Uint8Array([id]),
name,
type: "album",
updationTime: 1,
isShared: false,
});
const file = (id: number, collectionID: number): EnteFile => ({
id,
collectionID,
ownerID: USER_ID,
key: new Uint8Array([id]),
metadata: {
title: `file-${id}.jpg`,
fileType: "image",
creationTime: TAKEN_MS * 1000,
modificationTime: TAKEN_MS * 1000,
},
file: { decryptionHeader: "aGVhZGVy" },
thumbnail: { decryptionHeader: "dGh1bWI=" },
updationTime: 1,
});
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "quak-download-albums-"));
});
afterEach(() => {
if (root && existsSync(root))
rmSync(root, { recursive: true, force: true });
});
// Every file and directory under `dir`, by path.
const entries = (dir: string): string[] =>
readdirSync(dir, { recursive: true, encoding: "utf-8" });
// Set the modification time of everything under `dir` to the epoch, so that
// anything written there afterwards has a later one, however soon it comes.
const backdate = (dir: string): void => {
for (const name of entries(dir)) utimesSync(join(dir, name), 0, 0);
};
// The modification time of everything under `dir`, by path.
const mtimes = (dir: string): Map<string, number> =>
new Map(
entries(dir).map((name) => [name, statSync(join(dir, name)).mtimeMs]),
);
const readJSON = (path: string): unknown =>
JSON.parse(readFileSync(path, "utf-8"));
describe("examples/download-albums.ts", () => {
it("downloads every album's photos with their metadata, and on a second run only what the account gained", async () => {
// Album 1, "Trip", holds photos 1 and 2. Album 2, "Family", holds
// photo 2 and photo 3, a live photo.
const live = await asLivePhoto(
file(3, 2),
livePhotoZip({ "image.heic": HEIC_WITH_EXIF, "video.mov": VIDEO }),
livePhotoHash(HEIC_WITH_EXIF, VIDEO),
);
const collections = [collection(1, "Trip"), collection(2, "Family")];
const filesByAlbum = new Map<number, EnteFile[]>([
[1, [file(1, 1), file(2, 1)]],
[2, [file(2, 2), live.file]],
]);
const client = {
whoami: () => ({ email: "u@example.com", userID: USER_ID }),
collectionsSince: async (): Promise<CollectionsPage> => ({
collections: [...collections],
deleted: [],
cursor: 1,
}),
filesSince: async (args: {
collectionID: number;
}): Promise<FilesPage> => ({
files: filesByAlbum.get(args.collectionID) ?? [],
deleted: [],
cursor: 1,
}),
};
// Photo 3 comes from a stand-in server, encrypted as Ente serves a
// live photo. Any other original is a few bytes naming its photo.
// `calls` counts every fetch, thumbnails included.
const server = cdnSource(new Map([[3, live.body]]));
let calls = 0;
const source: ContentSource = {
original: async (args) => {
calls++;
if (args.file.id === 3) return server.original(args);
const bytes = `original-${args.file.id}`;
writeFileSync(args.destination, bytes);
return { bytesWritten: bytes.length };
},
thumbnail: async (args) => {
calls++;
return server.thumbnail(args);
},
};
const dir = join(root, "photos");
const open = (): Promise<Library> =>
Library.open({
client,
cacheDirectory: join(root, "cache"),
downloadDirectory: dir,
contentSource: source,
refreshIntervalSeconds: 3600,
precacheThumbnails: false,
precacheOriginals: false,
// The test counts fetches, so the free space of the disk it
// runs on must not shrink the cache and evict photo 1.
freeBelowBytes: 0,
});
const first = await open();
// The cache already holds photo 1's original, so its record names a
// cache path, which the metadata leaves out. download() copies it
// from the cache rather than fetching it again.
await first.photos.byID({ fileID: 1 })!.original();
expect(await downloadAlbums(first, dir)).toEqual({
downloaded: 3,
alreadyLocal: 0,
});
await first.close();
expect(calls).toBe(3);
// Each original at its save path, a live photo as its image, its video
// and the file naming them, and each photo's metadata beside it.
const day = join(dir, DAY);
expect(readdirSync(day).sort()).toEqual([
"2026-03-01.1.jpg",
"2026-03-01.1.jpg.json",
"2026-03-01.2.jpg",
"2026-03-01.2.jpg.json",
"2026-03-01.3.heic",
"2026-03-01.3.heic.json",
"2026-03-01.3.livephoto.json",
"2026-03-01.3.mov",
]);
expect(readFileSync(join(day, "2026-03-01.1.jpg"), "utf-8")).toBe(
"original-1",
);
expect(readFileSync(join(day, "2026-03-01.2.jpg"), "utf-8")).toBe(
"original-2",
);
expect(readFileSync(join(day, "2026-03-01.3.heic"))).toEqual(
Buffer.from(HEIC_WITH_EXIF),
);
expect(readFileSync(join(day, "2026-03-01.3.mov"))).toEqual(
Buffer.from(VIDEO),
);
// The metadata is the photo's record and its EXIF tags. The originals
// of photos 1 and 2 are not image data, so they have no EXIF tags.
// Photo 3's are every tag of its image, as `photo.exif()` returns
// them. `record` holds the fields the three records share.
const record = {
takenAt: TAKEN_MS,
modifiedAt: TAKEN_MS,
fileType: "image",
isArchived: false,
isHidden: false,
};
expect(readJSON(join(day, "2026-03-01.1.jpg.json"))).toEqual({
...record,
fileID: 1,
albumIDs: [1],
title: "file-1.jpg",
exif: {},
});
expect(readJSON(join(day, "2026-03-01.2.jpg.json"))).toEqual({
...record,
fileID: 2,
albumIDs: [1, 2],
title: "file-2.jpg",
exif: {},
});
expect(readJSON(join(day, "2026-03-01.3.heic.json"))).toEqual({
...record,
fileID: 3,
albumIDs: [2],
title: "file-3.jpg",
fileType: "livePhoto",
hash: livePhotoHash(HEIC_WITH_EXIF, VIDEO),
exif: readAllExifTags(HEIC_WITH_EXIF),
});
// Each album's photos, newest first, by save path relative to `dir`.
// Photo 2 is in both.
expect(readdirSync(join(dir, "albums")).sort()).toEqual([
"1.json",
"2.json",
]);
expect(readJSON(join(dir, "albums", "1.json"))).toEqual({
collectionID: 1,
name: "Trip",
savePaths: [
join(DAY, "2026-03-01.2.jpg"),
join(DAY, "2026-03-01.1.jpg"),
],
});
expect(readJSON(join(dir, "albums", "2.json"))).toEqual({
collectionID: 2,
name: "Family",
savePaths: [
join(DAY, "2026-03-01.3.heic"),
join(DAY, "2026-03-01.2.jpg"),
],
});
// Before the second run the account gains album 3, "Garden", holding
// a new photo 4. The second run, with a newly opened library, opens
// the cache written by the first and still downloads photo 4. It finds
// the other photos already local and fetches nothing else.
collections.push(collection(3, "Garden"));
filesByAlbum.set(3, [file(4, 3)]);
backdate(dir);
const before = mtimes(dir);
const second = await open();
expect(await downloadAlbums(second, dir)).toEqual({
downloaded: 1,
alreadyLocal: 3,
});
await second.close();
expect(calls).toBe(4);
expect(readFileSync(join(day, "2026-03-01.4.jpg"), "utf-8")).toBe(
"original-4",
);
expect(readJSON(join(dir, "albums", "3.json"))).toEqual({
collectionID: 3,
name: "Garden",
savePaths: [join(DAY, "2026-03-01.4.jpg")],
});
// The second run adds only photo 4's files and album 3's, and
// rewrites, renames or removes no file from the first run. The two
// directories that gain a file are the only other changes.
const after = mtimes(dir);
expect(
[...after.keys()].filter((name) => !before.has(name)).sort(),
).toEqual([
join(DAY, "2026-03-01.4.jpg"),
join(DAY, "2026-03-01.4.jpg.json"),
join("albums", "3.json"),
]);
for (const [name, mtime] of before) {
if (name === DAY || name === "albums") continue;
expect(after.get(name), name).toBe(mtime);
}
});
});
-27
View File
@@ -1,27 +0,0 @@
/**
* `exif.heic`, beside this file: a real 64x64 HEIC whose EXIF holds the same
* values as the hand-built JPEG in `exif-jpeg.ts`, for the tests of `exif()`,
* `backup-metadata --exif` and the image metadata `quak backup` records.
*
* It was made once, in a throwaway node:22-alpine container (Alpine 3.23.3),
* with libheif 1.23.0 and exiftool 13.55:
*
* apk add libheif-tools exiftool imagemagick
* magick -size 64x64 gradient:red-blue -depth 8 in.png
* heif-enc -q 30 -o exif.heic in.png
* exiftool -overwrite_original \
* -Make=Canon -Model="EOS R5" -LensModel="RF50mm F1.8 STM" \
* -DateTimeOriginal="2021:07:15 14:30:00" -OffsetTimeOriginal="+02:00" \
* -ExposureTime=1/250 -FNumber=2.8 -ISO=400 -FocalLength=50 \
* -Orientation#=6 \
* -GPSLatitude="40 26 46" -GPSLatitudeRef=N \
* -GPSLongitude="79 58 56" -GPSLongitudeRef=W \
* -GPSAltitude=12.5 -GPSAltitudeRef#=1 \
* exif.heic
*/
import { readFileSync } from "node:fs";
export const HEIC_WITH_EXIF = new Uint8Array(
readFileSync(new URL("exif.heic", import.meta.url)),
);
-89
View File
@@ -1,89 +0,0 @@
/**
* 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
]);
BIN
View File
Binary file not shown.
-1
View File
@@ -142,7 +142,6 @@ const buildCache = (args: {
pools: new RequestPools(),
source,
cacheDirectory: cacheDir,
downloadDirectory: join(root, "photos"),
getFile: (id) => byID.get(id),
statfs: args.statfs,
cacheOriginalsMaxBytes: args.cacheOriginalsMaxBytes,
+11 -544
View File
@@ -6,18 +6,15 @@
* `Photo` objects that fetch through it, `lib.thumbnails.ensure` drives it, and
* a cached path shows up on the projected record. A library opened without a
* content source leaves those methods throwing rather than silently doing
* nothing. It also covers a `Photo`'s `savePath`, `isLocal`, `download()`,
* `content()`, `exif()` and the methods that each return one EXIF field.
* nothing.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
existsSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
@@ -27,26 +24,10 @@ import { Library, type LibraryOptions } from "../../src/library/index.js";
import type { ContentSource } from "../../src/library/content.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
import { readPhotoExif, type PhotoExif } from "../../src/exif.js";
import { HEIC_WITH_EXIF } from "../exif-heic.js";
import { JPEG_WITH_BAD_EXIF, JPEG_WITH_EXIF } from "../exif-jpeg.js";
import {
asLivePhoto,
cdnSource,
IMAGE,
livePhotoHash,
livePhotoZip,
VIDEO,
} from "../live-photo.js";
import { asLivePhoto, cdnSource, livePhotoZip } from "../live-photo.js";
const USER_ID = 7;
// Every file is taken at noon local time on 2026-03-01, in microseconds as Ente
// stores times, so the machine's time zone cannot move it to another day; it
// is saved in the folder `DAY`.
const TAKEN = new Date(2026, 2, 1, 12).getTime() * 1000;
const DAY = join("2026", "2026-03", "2026-03-01");
const collection = (id: number): Collection => ({
id,
ownerID: USER_ID,
@@ -65,7 +46,7 @@ const file = (id: number, collectionID: number): EnteFile => ({
metadata: {
title: `file-${id}.jpg`,
fileType: "image",
creationTime: TAKEN,
creationTime: 1,
modificationTime: 1,
},
file: { decryptionHeader: "aGVhZGVy" },
@@ -89,23 +70,14 @@ class MockClient {
}
}
// A content source that writes `original` as every original and a marker file
// as every thumbnail, and counts the fetches of each.
const stubSource = (
original: string | Uint8Array = "orig-bytes",
): ContentSource & {
originalCalls: () => number;
thumbCalls: () => number;
} => {
let originalCalls = 0;
// A content source that writes a marker file and counts thumbnail fetches.
const stubSource = (): ContentSource & { thumbCalls: () => number } => {
let thumbCalls = 0;
return {
originalCalls: () => originalCalls,
thumbCalls: () => thumbCalls,
original: async ({ destination }) => {
originalCalls++;
writeFileSync(destination, original);
return { bytesWritten: original.length };
writeFileSync(destination, "orig-bytes");
return { bytesWritten: 10 };
},
thumbnail: async ({ destination }) => {
thumbCalls++;
@@ -177,7 +149,7 @@ describe("Library content wiring", () => {
await lib.close();
});
it("does not take a live photo's image or video with no JSON file as its original when it opens, and precaches both", async () => {
it("removes a live photo's ZIP an earlier version cached when it opens, and precaches its image and video", async () => {
const { file: live, body } = await asLivePhoto(file(1, 1));
class LiveClient extends MockClient {
override async filesSince(): Promise<FilesPage> {
@@ -198,8 +170,7 @@ describe("Library content wiring", () => {
// A first run records the library, so the next one knows that file 1
// is a live photo when it opens the cache.
await (await open({})).close();
writeFileSync(join(originals, "1.heic"), "an image");
writeFileSync(join(originals, "1.mov"), "a video");
writeFileSync(join(originals, "1.jpg"), livePhotoZip());
let precached!: () => void;
const done = new Promise<void>((r) => (precached = r));
@@ -210,9 +181,7 @@ describe("Library content wiring", () => {
precached();
},
});
expect(
lib.photos.byID({ fileID: 1 })!.record().originalPath,
).toBeUndefined();
expect(existsSync(join(originals, "1.jpg"))).toBe(false);
await done;
expect(readdirSync(originals).sort()).toEqual([
@@ -220,17 +189,6 @@ describe("Library content wiring", () => {
"1.livephoto.json",
"1.mov",
]);
expect(readFileSync(join(originals, "1.heic"))).toEqual(
Buffer.from(IMAGE),
);
expect(readFileSync(join(originals, "1.mov"))).toEqual(
Buffer.from(VIDEO),
);
expect(
JSON.parse(
readFileSync(join(originals, "1.livephoto.json"), "utf-8"),
),
).toEqual({ image: "1.heic", video: "1.mov" });
expect(lib.photos.byID({ fileID: 1 })!.record().originalPath).toBe(
join(originals, "1.heic"),
);
@@ -247,500 +205,9 @@ describe("Library content wiring", () => {
await expect(
lib.photos.byID({ fileID: 1 })!.thumbnail(),
).rejects.toThrow(/content cache/i);
await expect(
lib.photos.byID({ fileID: 1 })!.download(),
).rejects.toThrow(/content cache/i);
await expect(
lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }),
).rejects.toThrow(/content cache/i);
await lib.close();
});
});
describe("Photo save path, local copy, content and EXIF", () => {
// The same account, with `files` in its album instead.
class FilesClient extends MockClient {
constructor(private readonly files: EnteFile[]) {
super();
}
override async filesSince(): Promise<FilesPage> {
return { files: this.files, deleted: [], cursor: 1 };
}
}
const open = (opts: Partial<LibraryOptions> = {}): Promise<Library> =>
Library.open({
client: new MockClient(),
cacheDirectory: join(root, "cache"),
downloadDirectory: join(root, "backup"),
contentSource: stubSource(),
refreshIntervalSeconds: 3600,
precacheThumbnails: false,
precacheOriginals: false,
...opts,
});
it("names where a backup writes the original, which is local once the backup has written it", async () => {
const lib = await open();
const photo = lib.photos.byID({ fileID: 1 })!;
const savePath = join(root, "backup", DAY, "2026-03-01.1.jpg");
expect(photo.savePath).toBe(savePath);
expect(photo.isLocal).toBe(false);
await lib.backup();
expect(photo.savePath).toBe(savePath);
expect(existsSync(savePath)).toBe(true);
expect(photo.isLocal).toBe(true);
await lib.close();
});
it("returns the original's bytes, and a copy only in the cache is not local", async () => {
const lib = await open();
const photo = lib.photos.byID({ fileID: 1 })!;
expect(await photo.content()).toEqual(Buffer.from("orig-bytes"));
expect(existsSync(join(root, "cache", "originals", "1.jpg"))).toBe(
true,
);
expect(existsSync(photo.savePath)).toBe(false);
expect(photo.isLocal).toBe(false);
await lib.close();
});
it("saves under photos/ in the working directory at open without a download directory", async () => {
const cwd = vi.spyOn(process, "cwd").mockReturnValue(root);
const lib = await open({ downloadDirectory: undefined });
cwd.mockRestore();
const photo = lib.photos.byID({ fileID: 1 })!;
expect(lib.downloadDirectory).toBe(join(root, "photos"));
expect(photo.savePath).toBe(
join(root, "photos", DAY, "2026-03-01.1.jpg"),
);
expect(photo.isLocal).toBe(false);
await lib.close();
});
it("refuses an empty download directory", async () => {
await expect(open({ downloadDirectory: "" })).rejects.toThrow(
/downloadDirectory is empty/,
);
});
it("keeps a photo's save path after a refresh removes its file", async () => {
// The same account, whose album is deleted on the second refresh.
class AlbumDeletedClient extends MockClient {
override async collectionsSince(): Promise<CollectionsPage> {
if (!this.served) return super.collectionsSince();
return { collections: [], deleted: [1], cursor: 2 };
}
}
const lib = await open({ client: new AlbumDeletedClient() });
const photo = lib.photos.byID({ fileID: 1 })!;
await photo.download();
await lib.fresh();
expect(lib.photos.byID({ fileID: 1 })).toBeUndefined();
expect(photo.savePath).toBe(
join(root, "backup", DAY, "2026-03-01.1.jpg"),
);
expect(photo.isLocal).toBe(true);
await lib.close();
});
it("dates the save path and download() by the membership its takenAt comes from", async () => {
// One file in two albums. The date edited in Ente has reached album 2's
// copy, synced later, but album 1 still has an earlier edit.
const older = file(1, 1);
older.pubMagicMetadata = {
editedTime: new Date(2026, 1, 1, 12).getTime() * 1000,
};
const newer = file(1, 2);
newer.updationTime = 2;
newer.pubMagicMetadata = {
editedTime: new Date(2026, 3, 15, 12).getTime() * 1000,
};
const client = {
whoami: () => ({ email: "u@example.com", userID: USER_ID }),
collectionsSince: async (): Promise<CollectionsPage> => ({
collections: [collection(1), collection(2)],
deleted: [],
cursor: 1,
}),
filesSince: async (args: {
collectionID: number;
}): Promise<FilesPage> => ({
files: [args.collectionID === 1 ? older : newer],
deleted: [],
cursor: 1,
}),
};
const lib = await open({ client });
const photo = lib.photos.byID({ fileID: 1 })!;
expect(photo.takenAt).toBe(new Date(2026, 3, 15, 12).getTime());
expect(photo.savePath).toBe(
join(
root,
"backup",
"2026",
"2026-04",
"2026-04-15",
"2026-04-15.1.jpg",
),
);
// download() writes to that same path, so the photo is then local.
const saved = await photo.download();
expect(saved.path).toBe(photo.savePath);
expect(photo.isLocal).toBe(true);
expect(existsSync(join(root, "backup", "2026", "2026-02"))).toBe(false);
await lib.close();
});
it("downloads a photo held across a refresh that edits its date to the save path it names", async () => {
// The same account, whose second refresh brings a date edited in Ente.
const edited = file(1, 1);
edited.updationTime = 2;
edited.pubMagicMetadata = {
editedTime: new Date(2026, 3, 15, 12).getTime() * 1000,
};
class DateEditedClient extends MockClient {
refreshes = 0;
override async collectionsSince(): Promise<CollectionsPage> {
this.refreshes++;
return {
collections: [
{ ...collection(1), updationTime: this.refreshes },
],
deleted: [],
cursor: this.refreshes,
};
}
override async filesSince(): Promise<FilesPage> {
return {
files: [this.refreshes === 1 ? file(1, 1) : edited],
deleted: [],
cursor: this.refreshes,
};
}
}
const lib = await open({ client: new DateEditedClient() });
const photo = lib.photos.byID({ fileID: 1 })!;
await lib.fresh();
expect(lib.photos.byID({ fileID: 1 })!.takenAt).toBe(
new Date(2026, 3, 15, 12).getTime(),
);
const saved = await photo.download();
expect(saved.path).toBe(photo.savePath);
expect(saved.path).toBe(join(root, "backup", DAY, "2026-03-01.1.jpg"));
expect(photo.isLocal).toBe(true);
await lib.close();
});
it("has a save path, and is not local, without a content source", async () => {
const lib = await open({ contentSource: undefined });
const photo = lib.photos.byID({ fileID: 1 })!;
expect(photo.savePath).toBe(
join(root, "backup", DAY, "2026-03-01.1.jpg"),
);
expect(photo.isLocal).toBe(false);
await lib.close();
});
it("gives a live photo's image as its save path once a backup has stored it", async () => {
const { file: live, body } = await asLivePhoto(file(1, 1));
const lib = await open({
client: new FilesClient([live]),
contentSource: cdnSource(new Map([[1, body]])),
});
const photo = lib.photos.byID({ fileID: 1 })!;
const day = join(root, "backup", DAY);
// Until then the name comes from the title, file-1.jpg; the backup
// stores the image with the extension found inside the live photo.
expect(photo.savePath).toBe(join(day, "2026-03-01.1.jpg"));
await lib.backup();
expect(photo.savePath).toBe(join(day, "2026-03-01.1.heic"));
expect(photo.isLocal).toBe(true);
expect(await photo.content()).toEqual(Buffer.from(IMAGE));
await lib.close();
});
it("downloads an original the cache holds by copying it, without fetching it again", async () => {
const source = stubSource();
const lib = await open({ contentSource: source });
const photo = lib.photos.byID({ fileID: 1 })!;
await photo.original();
expect(source.originalCalls()).toBe(1);
expect(photo.isLocal).toBe(false);
const saved = await photo.download();
expect(source.originalCalls()).toBe(1);
expect(saved).toEqual({
path: photo.savePath,
bytes: "orig-bytes".length,
});
expect(readFileSync(photo.savePath, "utf-8")).toBe("orig-bytes");
expect(photo.isLocal).toBe(true);
// The cache keeps its own copy.
expect(existsSync(join(root, "cache", "originals", "1.jpg"))).toBe(
true,
);
await lib.close();
});
it("downloads an original the cache does not hold straight to its save path", async () => {
const source = stubSource();
const lib = await open({ contentSource: source });
const photo = lib.photos.byID({ fileID: 1 })!;
const saved = await photo.download();
expect(source.originalCalls()).toBe(1);
expect(saved.path).toBe(join(root, "backup", DAY, "2026-03-01.1.jpg"));
expect(readFileSync(saved.path, "utf-8")).toBe("orig-bytes");
expect(photo.isLocal).toBe(true);
expect(readdirSync(join(root, "cache", "originals"))).toEqual([]);
await lib.close();
});
it("does nothing when download() finds the original already at its save path", async () => {
const source = stubSource();
const lib = await open({ contentSource: source });
const photo = lib.photos.byID({ fileID: 1 })!;
const first = await photo.download();
const written = statSync(first.path).ino;
const second = await photo.download();
expect(second).toEqual(first);
expect(source.originalCalls()).toBe(1);
expect(statSync(second.path).ino).toBe(written);
await lib.close();
});
it("downloads a live photo the cache holds as its image, its video and the JSON file naming them", async () => {
const { file: live, body } = await asLivePhoto(file(1, 1));
const lib = await open({
client: new FilesClient([live]),
contentSource: cdnSource(new Map([[1, body]])),
});
const photo = lib.photos.byID({ fileID: 1 })!;
await photo.original();
const day = join(root, "backup", DAY);
const saved = await photo.download();
expect(saved).toEqual({
path: join(day, "2026-03-01.1.heic"),
videoPath: join(day, "2026-03-01.1.mov"),
bytes: IMAGE.length,
});
expect(readdirSync(day).sort()).toEqual([
"2026-03-01.1.heic",
"2026-03-01.1.livephoto.json",
"2026-03-01.1.mov",
]);
expect(
JSON.parse(
readFileSync(join(day, "2026-03-01.1.livephoto.json"), "utf-8"),
),
).toEqual({ image: "2026-03-01.1.heic", video: "2026-03-01.1.mov" });
expect(photo.savePath).toBe(saved.path);
expect(photo.isLocal).toBe(true);
await lib.close();
});
// Every tag in JPEG_WITH_EXIF, by name, in the order of its IFDs.
const jpegTags = [
"Make",
"Model",
"Orientation",
"Exif IFD Pointer",
"GPS Info IFD Pointer",
"ExposureTime",
"FNumber",
"ISOSpeedRatings",
"DateTimeOriginal",
"OffsetTimeOriginal",
"FocalLength",
"LensModel",
"GPSLatitudeRef",
"GPSLatitude",
"GPSLongitudeRef",
"GPSLongitude",
"GPSAltitudeRef",
"GPSAltitude",
];
// HEIC_WITH_EXIF holds those and the tags exiftool adds to every file.
const heicTags = [
...jpegTags,
"YCbCrPositioning",
"ExifVersion",
"ComponentsConfiguration",
"ColorSpace",
"GPSVersionID",
];
it.each([
[
"JPEG",
JPEG_WITH_EXIF,
jpegTags,
{
"Exif IFD Pointer": { value: 88 },
GPSLatitudeRef: { value: ["N"], description: "North latitude" },
},
],
[
"HEIC",
HEIC_WITH_EXIF,
heicTags,
{
ColorSpace: { value: 0xffff, description: "Uncalibrated" },
ExifVersion: { description: "0232" },
},
],
])(
"returns every EXIF tag of a %s original, by name",
async (_, bytes, names, others) => {
const lib = await open({ contentSource: stubSource(bytes) });
const exif = await lib.photos.byID({ fileID: 1 })!.exif();
expect(Object.keys(exif).sort()).toEqual([...names].sort());
// Tags outside the thirteen fields, as exifreader decodes them.
expect(exif).toMatchObject(others);
await lib.close();
},
);
it("picks the common EXIF fields from a JPEG original's tags", async () => {
const lib = await open({ contentSource: stubSource(JPEG_WITH_EXIF) });
const exif = await lib.photos.byID({ fileID: 1 })!.exif();
expect(readPhotoExif(exif)).toStrictEqual({
make: "Canon",
model: "EOS R5",
lensModel: "RF50mm F1.8 STM",
// The camera's clock reading, held in the Date's UTC fields.
dateTimeOriginal: new Date(Date.UTC(2021, 6, 15, 14, 30)),
offsetTimeOriginal: "+02:00",
exposureTime: 1 / 250,
fNumber: 2.8,
iso: 400,
focalLength: 50,
orientation: 6,
gpsLatitude: 40 + 26 / 60 + 46 / 3600,
gpsLongitude: -(79 + 58 / 60 + 56 / 3600),
gpsAltitude: -12.5,
});
await lib.close();
});
// The fields picked from HEIC_WITH_EXIF's tags, and from JPEG_WITH_EXIF's,
// which hold the same values.
const heicFields: PhotoExif = {
make: "Canon",
model: "EOS R5",
lensModel: "RF50mm F1.8 STM",
dateTimeOriginal: new Date(Date.UTC(2021, 6, 15, 14, 30)),
offsetTimeOriginal: "+02:00",
exposureTime: 1 / 250,
fNumber: 2.8,
iso: 400,
focalLength: 50,
orientation: 6,
gpsLatitude: 40 + 26 / 60 + 46 / 3600,
gpsLongitude: -(79 + 58 / 60 + 56 / 3600),
gpsAltitude: -12.5,
};
it("picks the same common EXIF fields from a HEIC original's tags", async () => {
const lib = await open({ contentSource: stubSource(HEIC_WITH_EXIF) });
const exif = await lib.photos.byID({ fileID: 1 })!.exif();
expect(readPhotoExif(exif)).toStrictEqual(heicFields);
await lib.close();
});
it("reads the EXIF of a live photo whose image is a HEIC", async () => {
const { file: live, body } = await asLivePhoto(
file(1, 1),
livePhotoZip({ "image.heic": HEIC_WITH_EXIF, "video.mov": VIDEO }),
livePhotoHash(HEIC_WITH_EXIF, VIDEO),
);
const lib = await open({
client: new FilesClient([live]),
contentSource: cdnSource(new Map([[1, body]])),
});
const exif = await lib.photos.byID({ fileID: 1 })!.exif();
expect(readPhotoExif(exif)).toStrictEqual(heicFields);
await lib.close();
});
// The build's type check, not this test, makes sure `Photo` has a method
// for every `PhotoExif` field, whatever the fixtures hold: `Photo`
// implements a type with one method per field. This test checks that each
// method gives the field picked from the tags exif() returns.
it.each([
["JPEG", JPEG_WITH_EXIF],
["HEIC", HEIC_WITH_EXIF],
])(
"has a method for each field, agreeing with the tags exif() returns, for a %s",
async (_, bytes) => {
const lib = await open({ contentSource: stubSource(bytes) });
const photo = lib.photos.byID({ fileID: 1 })!;
const fields = readPhotoExif(await photo.exif());
// The file holds every field, so every method is checked.
expect(fields).toStrictEqual(heicFields);
for (const [field, value] of Object.entries(fields)) {
expect(await photo[field as keyof PhotoExif]()).toStrictEqual(
value,
);
}
await lib.close();
},
);
it("returns no EXIF tags for an original that is not an image", async () => {
const lib = await open();
const photo = lib.photos.byID({ fileID: 1 })!;
expect(await photo.exif()).toStrictEqual({});
expect(await photo.dateTimeOriginal()).toBeUndefined();
await lib.close();
});
it("returns no EXIF tags for a JPEG whose EXIF cannot be parsed", async () => {
const lib = await open({
contentSource: stubSource(JPEG_WITH_BAD_EXIF),
});
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({});
await lib.close();
});
it("throws from exif() on a video without a content source, as the other content methods do", async () => {
const video = file(1, 1);
video.metadata.fileType = "video";
const lib = await open({
client: new FilesClient([video]),
contentSource: undefined,
});
await expect(lib.photos.byID({ fileID: 1 })!.exif()).rejects.toThrow(
/content cache/i,
);
await lib.close();
});
it("returns no EXIF tags for a video, without fetching it", async () => {
const video = file(1, 1);
video.metadata.fileType = "video";
const source = stubSource(JPEG_WITH_EXIF);
const lib = await open({
client: new FilesClient([video]),
contentSource: source,
});
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({});
expect(source.originalCalls()).toBe(0);
await lib.close();
});
});
+43 -80
View File
@@ -7,8 +7,8 @@
* 1. **Fetch once, then serve from disk.** The first `original`/`thumbnail`
* fetches through the request pool and stores the bytes; the next finds the
* file present and returns its path with a single `skipped` event and no
* network. A file already stored at its save path under the
* `downloadDirectory` counts as present too.
* network. A file already sitting in the backup `downloadDirectory` counts
* as present too.
* 2. **Present-means-complete.** Content appears only by the streaming atomic
* writer's rename, so a file that exists is whole. The directory listing
* taken at `open()` is the record of what is cached, and the orphan temp
@@ -41,7 +41,6 @@ import { join } from "node:path";
import {
ContentCache,
savePath,
type ContentSource,
type EnsureEvent,
} from "../../src/library/content.js";
@@ -153,48 +152,12 @@ const buildCache = (
pools: args.pools ?? new RequestPools(),
source,
cacheDirectory: cacheDir,
downloadDirectory: args.downloadDirectory ?? join(root, "photos"),
downloadDirectory: args.downloadDirectory,
getFile: (id) => byID.get(id),
});
return { cache, source };
};
// Microseconds, as Ente stores times, for noon local time on a day, so the
// machine's time zone cannot move the photo to another day.
const noon = (year: number, month: number, day: number): number =>
new Date(year, month - 1, day, 12).getTime() * 1000;
describe("savePath", () => {
it("files an original by year, month and day under the root", () => {
const f = file(12345, "IMG_0001.HEIC");
f.metadata.creationTime = noon(2026, 3, 1);
expect(savePath("/photos", f)).toBe(
"/photos/2026/2026-03/2026-03-01/2026-03-01.12345.HEIC",
);
});
it("dates an original by the date the user set, when there is one", () => {
const f = file(7, "a.jpg");
f.metadata.creationTime = noon(2026, 3, 1);
f.pubMagicMetadata = { editedTime: noon(1999, 12, 31) };
expect(savePath("/photos", f)).toBe(
"/photos/1999/1999-12/1999-12-31/1999-12-31.7.jpg",
);
});
it("takes the extension from the title it was uploaded with, not a new name", () => {
const f = file(8, "upload");
f.metadata.creationTime = noon(2026, 3, 1);
f.pubMagicMetadata = { editedName: "renamed.png" };
expect(savePath("/photos", f)).toBe(
"/photos/2026/2026-03/2026-03-01/2026-03-01.8.bin",
);
});
});
describe("ContentCache.open", () => {
it("creates the cache directories with 0700 permissions", async () => {
const { cache } = buildCache();
@@ -311,17 +274,11 @@ describe("ContentCache.original / thumbnail", () => {
it("serves a file already present in the download directory without fetching", async () => {
const downloadDirectory = join(root, "backup");
const day = join(downloadDirectory, "2026", "2026-03", "2026-03-01");
mkdirSync(day, { recursive: true });
const backupPath = join(day, "2026-03-01.1.jpg");
mkdirSync(join(downloadDirectory, "originals"), { recursive: true });
const backupPath = join(downloadDirectory, "originals", "1.jpg");
writeFileSync(backupPath, "from-backup");
const f = file(1);
f.metadata.creationTime = noon(2026, 3, 1);
const { cache, source } = buildCache({
downloadDirectory,
files: [f],
});
const { cache, source } = buildCache({ downloadDirectory });
await cache.open();
const events: EnsureEvent["status"][] = [];
@@ -566,6 +523,43 @@ describe("ContentCache live photos", () => {
expect(events).toEqual(["skipped"]);
});
it("replaces a live photo an earlier version stored as a ZIP under the image's name", async () => {
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
mkdirSync(originals(), { recursive: true });
writeFileSync(join(originals(), "5.HEIC"), livePhotoZip());
const cache = cacheOf([live], new Map([[5, body]]));
// Opened without being told that file 5 is a live photo, the cache
// records the ZIP, and does not serve it.
await cache.open();
const result = await cache.original(5);
expect(result.videoPath).toBe(join(originals(), "5.mov"));
expect(readdirSync(originals()).sort()).toEqual([
"5.heic",
"5.livephoto.json",
"5.mov",
]);
});
it("removes a live photo's ZIP an earlier version stored when it opens, so the precache fetches the image and video", async () => {
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
mkdirSync(originals(), { recursive: true });
writeFileSync(join(originals(), "5.HEIC"), livePhotoZip());
const cache = cacheOf([live], new Map([[5, body]]));
await cache.open((fileID) => fileID === 5);
expect(readdirSync(originals())).toEqual([]);
expect(cache.pathsFor(5)).toEqual({});
const [fetched] = await cache.ensureOriginals({ fileIDs: [5] });
expect(fetched).toEqual({
fileID: 5,
path: join(originals(), "5.heic"),
});
expect(cache.pathsFor(5)).toEqual({ originalPath: fetched!.path });
});
it("leaves the image and video another process has just stored when it opens before their JSON file is written", async () => {
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
const server = cdnSource(new Map([[5, body]]));
@@ -596,36 +590,6 @@ describe("ContentCache live photos", () => {
expect(second!.pathsFor(5)).toEqual({});
});
it("fetches a live photo's image and video again when the cache opened before knowing it is a live photo and no JSON file names them", async () => {
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
mkdirSync(originals(), { recursive: true });
writeFileSync(join(originals(), "5.heic"), "an image");
writeFileSync(join(originals(), "5.mov"), "a video");
const cache = cacheOf([live], new Map([[5, body]]));
// Opened without being told that file 5 is a live photo, the cache
// records one of the two files as its original, with no video.
await cache.open();
const events: string[] = [];
const result = await cache.original(5, {
onProgress: (e) => events.push(e.status),
});
expect(events.at(-1)).toBe("done");
expect(result).toEqual({
path: join(originals(), "5.heic"),
videoPath: join(originals(), "5.mov"),
bytes: IMAGE.length,
});
expect(readFileSync(result.path)).toEqual(Buffer.from(IMAGE));
expect(readFileSync(result.videoPath!)).toEqual(Buffer.from(VIDEO));
expect(
JSON.parse(
readFileSync(join(originals(), "5.livephoto.json"), "utf-8"),
),
).toEqual({ image: "5.heic", video: "5.mov" });
});
it.each(["missing", "empty"])(
"fetches a live photo again when the video its JSON file names is %s",
async (state) => {
@@ -685,7 +649,6 @@ describe("ContentCache live photos", () => {
]),
),
cacheDirectory: cacheDir,
downloadDirectory: join(root, "photos"),
getFile: (id) => [a.file, b.file].find((f) => f.id === id),
// Room for one live photo, on a disk with plenty free.
cacheOriginalsMaxBytes: size,
-2
View File
@@ -280,7 +280,6 @@ describe("Precache eviction integration", () => {
pools: new RequestPools(),
source,
cacheDirectory: cacheDir,
downloadDirectory: join(root, "photos"),
getFile: (id) => byID.get(id),
statfs,
cacheOriginalsMaxBytes: 25, // holds two 10-byte originals
@@ -349,7 +348,6 @@ describe("Precache preemption", () => {
pools: new RequestPools({ contentConcurrency: 1 }),
source,
cacheDirectory: join(root, "cache"),
downloadDirectory: join(root, "photos"),
getFile: (id) => byID.get(id),
statfs: async () => ({ bsize: 1, bavail: 1_000_000_000 }),
freeBelowBytes: 0,
+3 -34
View File
@@ -36,7 +36,6 @@ import {
makeAlbumsAPI,
makePhotosAPI,
makeTimelineAPI,
type SavePathLookup,
type TimelineGroup,
} from "../../src/library/read.js";
import { Library } from "../../src/library/index.js";
@@ -102,19 +101,12 @@ const file = (
};
// Build the three API objects over one fixed projection, the way `Library`
// wires them over its live store. Save paths are covered in
// content-library.test.ts; these tests never ask for one.
// wires them over its live store.
const apis = (records: DerivedRecords) => {
const derive = () => records;
const saves: SavePathLookup = {
savePath: () => {
throw new Error("no save paths in these tests");
},
isLocal: () => false,
};
return {
albums: makeAlbumsAPI(derive, saves),
photos: makePhotosAPI(derive, saves),
albums: makeAlbumsAPI(derive),
photos: makePhotosAPI(derive),
timeline: makeTimelineAPI(derive),
};
};
@@ -217,29 +209,6 @@ describe("lib.photos", () => {
expect("key" in photo.record()).toBe(false);
});
it("byID exposes modifiedAt, hash and the year taken", () => {
// Mid-July, so the year is 2021 in every time zone.
const takenAt = Date.UTC(2021, 6, 15, 12);
const records = deriveRecords(
[collection(1)],
[
file(1001, 1, {
metadata: {
title: "IMG.jpg",
fileType: "image",
creationTime: micros(takenAt),
modificationTime: micros(1_700_000_123_456),
hash: "aGFzaA==",
},
}),
],
);
const photo = apis(records).photos.byID({ fileID: 1001 })!;
expect(photo.modifiedAt).toBe(ms(1_700_000_123_456));
expect(photo.hash).toBe("aGFzaA==");
expect(photo.year).toBe(2021);
});
it("byID returns undefined for an unknown file id", () => {
const records = deriveRecords([collection(1)], [file(1, 1)]);
expect(apis(records).photos.byID({ fileID: 999 })).toBeUndefined();
-1
View File
@@ -159,7 +159,6 @@ describe("deriveRecords: photo mapping", () => {
expect("width" in rec).toBe(false);
expect("height" in rec).toBe(false);
expect("latitude" in rec).toBe(false);
expect("hash" in rec).toBe(false);
});
it("reads archived and hidden from private magicMetadata.visibility", () => {
-23
View File
@@ -172,29 +172,6 @@ describe("MetadataStore", () => {
expect(store.listCollections()).toEqual([]);
});
it("loads an empty store when the list of what the refresh could not decrypt is damaged", async () => {
// A backup reads that list on every run, so a damaged one that loaded
// would fail every backup until the cache was deleted. A string is
// damaged too, though JavaScript can loop over its characters.
mkdirSync(join(dir, "cache"), { recursive: true });
for (const undecryptable of [{}, "not a list", [1]]) {
writeFileSync(
path,
JSON.stringify({
schemaVersion: METADATA_SCHEMA_VERSION,
userID: 42,
collectionsSinceTime: 0,
collections: [],
files: [],
undecryptable,
}),
);
const store = await MetadataStore.load(path);
expect(store.userID).toBe(0);
expect(store.listUndecryptable()).toEqual([]);
}
});
it("creates the directory 0700 and the file 0600", async () => {
const store = await MetadataStore.load(path);
store.putCollection(sampleCollection());
+1 -2
View File
@@ -30,8 +30,7 @@ export const livePhotoZip = (
},
): Uint8Array => zipSync(entries);
// The content hash Ente's clients record for an original's bytes.
export const blake2b = (bytes: Uint8Array): string =>
const blake2b = (bytes: Uint8Array): string =>
createHash("blake2b512").update(bytes).digest("base64");
// The hash Ente's clients record for a live photo: the unkeyed BLAKE2b-512 of
+12 -26
View File
@@ -9,10 +9,8 @@
// Excluding too much: Prettier 3 reads `.gitignore` as a default ignore file,
// so dropping it from the context silently changes which files the lint
// phase's prettier check looks at compared to `make fmt-check` on the host.
// And without `.git`, a `docker build .` given no `VERSION` build arg cannot
// derive the version (`script/version`) and stamps `package.json`'s instead.
//
// None of these shows up as a build failure, so they are asserted here.
// Neither shows up as a build failure, so they are asserted here.
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
@@ -29,19 +27,18 @@ const patterns = (name: string): string[] =>
const dockerignore = patterns(".dockerignore");
describe(".dockerignore", () => {
// Everything here is either generated, enormous, or secret. `.claude` is
// the correctness one: see the header comment and issue #25. The leading
// `/` anchors an entry at the root of the context.
// Everything here is either generated, enormous, or secret. `.claude/` is
// the correctness one: see the header comment and issue #25.
it.each([
".claude",
"/.quak",
"/bin/quak",
"**/node_modules",
"/coverage",
"/dist",
"/.vitest-cache",
"/.nyc_output",
"/*.tsbuildinfo",
".claude/",
".quak/",
"bin/quak",
"node_modules",
"coverage",
"dist",
".vitest-cache/",
".nyc_output/",
"*.tsbuildinfo",
])("keeps %s out of the build context", (pattern) => {
expect(dockerignore).toContain(pattern);
});
@@ -50,17 +47,6 @@ describe(".dockerignore", () => {
expect(dockerignore).not.toContain(".gitignore");
});
it("leaves .git in the build context for the version", () => {
expect(dockerignore).not.toContain(".git");
expect(dockerignore).not.toContain(".git/");
});
// The build stage is the final image, so a .git/config sent in would
// ship the clone's remote URL and any credential in it.
it("sends .git without its config", () => {
expect(dockerignore).toContain("**/.git/config");
});
// BuildKit lets a `Dockerfile.dockerignore` shadow the root one; such a
// file would silently give the build a different, unreviewed context —
// and eslint's flat config does not ignore dot-directories, so a stray
-137
View File
@@ -1,137 +0,0 @@
// `script/version` prints the version `script/build` stamps into
// `dist/package.json`, which is what `quak --version` reports from a build.
// A `docker build .` of a clone is given no `VERSION` build arg, so the
// version has to come from the `.git` in its context: the tag on a tagged
// commit; the tag, the commits since it and the short commit on a later commit;
// the short commit when no tag is reachable. A checkout with `.git` that still
// yields no usable version must fail the build, not ship a version nobody can
// trace back to its commit.
//
// Each test copies the script into a fresh directory, which the script then
// treats as the checkout, and executes it there.
import { afterEach, describe, expect, it } from "vitest";
import { execFileSync, spawnSync } from "node:child_process";
import {
chmodSync,
copyFileSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
let checkout = "";
afterEach(() => {
rmSync(checkout, { recursive: true, force: true });
});
// A checkout holding the script and a package.json that declares 0.0.0, with
// no .git yet.
const makeCheckout = (): void => {
checkout = mkdtempSync(join(tmpdir(), "quak-version-"));
mkdirSync(join(checkout, "script"));
copyFileSync(
join(repoRoot, "script/version"),
join(checkout, "script/version"),
);
chmodSync(join(checkout, "script/version"), 0o755);
writeFileSync(join(checkout, "package.json"), '{ "version": "0.0.0" }\n');
};
// git in the checkout, with an identity and no commit signing, whatever the
// host's own git config says.
const git = (...args: string[]): string =>
execFileSync(
"git",
[
"-c",
"user.name=quak",
"-c",
"user.email=quak@example.invalid",
"-c",
"commit.gpgsign=false",
...args,
],
{ cwd: checkout, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
).trim();
const makeCommittedCheckout = (): void => {
makeCheckout();
git("init", "-q");
git("add", "package.json");
git("commit", "-q", "-m", "first");
};
// Runs the script with nothing in its environment but PATH and, when given,
// VERSION.
const runVersion = (version?: string) =>
spawnSync(join(checkout, "script/version"), {
cwd: checkout,
encoding: "utf-8",
env: { PATH: process.env.PATH, VERSION: version },
});
describe("script/version", () => {
it("prints the short commit of an untagged commit", () => {
makeCommittedCheckout();
expect(runVersion().stdout.trim()).toBe(
git("rev-parse", "--short", "HEAD"),
);
});
it("prints the tag of a tagged commit", () => {
makeCommittedCheckout();
git("tag", "v1.2.3");
expect(runVersion().stdout.trim()).toBe("v1.2.3");
});
// script/docker and script/cibuild pass the version they resolve on the
// host as the VERSION build arg.
it("prints the VERSION it is given over what git would derive", () => {
makeCommittedCheckout();
expect(runVersion("x").stdout.trim()).toBe("x");
});
// `--build-arg VERSION=` must not stamp an empty version.
it("treats an empty VERSION as unset", () => {
makeCommittedCheckout();
expect(runVersion("").stdout.trim()).toBe(
git("rev-parse", "--short", "HEAD"),
);
});
// A source tarball has no .git: it keeps the version package.json
// declares, and must still build.
it("prints package.json's version where there is no .git", () => {
makeCheckout();
const result = runVersion();
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("0.0.0");
});
// A repository with no commits stands in for any .git that git cannot
// describe: git missing from the image, or refusing to read the checkout.
it("fails where .git yields no version", () => {
makeCheckout();
git("init", "-q");
const result = runVersion();
expect(result.status).not.toBe(0);
expect(result.stdout).toBe("");
});
it.each(["dev", "unknown"])(
"fails where there is .git and the version is %s",
(version) => {
makeCommittedCheckout();
const result = runVersion(version);
expect(result.status).not.toBe(0);
expect(result.stdout).toBe("");
},
);
});
-31
View File
@@ -43,7 +43,6 @@ import {
isRetryable,
isSafeToReplay,
resolveRetryOptions,
UNATTENDED_RETRY_OPTIONS,
withRetry,
} from "../../src/retry.js";
import { ApiError, TruncatedStreamError } from "../../src/errors.js";
@@ -640,33 +639,3 @@ 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);
});
});
+1 -1
View File
@@ -18,5 +18,5 @@
"sourceMap": true,
"resolveJsonModule": true
},
"include": ["src/**/*", "bin/**/*", "examples/**/*"]
"include": ["src/**/*", "bin/**/*"]
}
+4 -47
View File
@@ -535,18 +535,6 @@
dependencies:
undici-types "~6.21.0"
"@types/proper-lockfile@4.1.4":
version "4.1.4"
resolved "https://registry.yarnpkg.com/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz#cd9fab92bdb04730c1ada542c356f03620f84008"
integrity sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==
dependencies:
"@types/retry" "*"
"@types/retry@*":
version "0.12.5"
resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.5.tgz#f090ff4bd8d2e5b940ff270ab39fd5ca1834a07e"
integrity sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==
"@typescript-eslint/eslint-plugin@8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz#dc4ab93ee3d7e6c8e38820a0d6c7c93c7183e2dc"
@@ -714,11 +702,6 @@
loupe "^3.1.2"
tinyrainbow "^1.2.0"
"@xmldom/xmldom@^0.9.10":
version "0.9.12"
resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.9.12.tgz#1f84c07cb95ccf28202299f77b5fd7fc257151e8"
integrity sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==
acorn-jsx@^5.3.2:
version "5.3.2"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
@@ -1019,12 +1002,10 @@ esutils@^2.0.2:
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
exifreader@4.46.0:
version "4.46.0"
resolved "https://registry.yarnpkg.com/exifreader/-/exifreader-4.46.0.tgz#b6216eae512997587c45114f972cc14ca979205f"
integrity sha512-ksHTpjXKWzbckY+bYlGaomG0EobHJkaMWLg5OPbzlTPda04F2dfrDfYSz8iAPp/kXYUSQdINBVI6nCAjQ8PQ/Q==
optionalDependencies:
"@xmldom/xmldom" "^0.9.10"
exif-reader@2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/exif-reader/-/exif-reader-2.0.3.tgz#259997735080bc6bb959c37b32c60f004ec4391d"
integrity sha512-zFbQvguwT9JkqyYhR7pjE1Yn8SagwaGLNRU0Oh14xFa1paSf5Gzxn4gxgk0XhnudI0UIqU+HgnBX93+nva592A==
expect-type@^1.1.0:
version "1.3.0"
@@ -1152,11 +1133,6 @@ globals@^14.0.0:
resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e"
integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
graceful-fs@^4.2.4:
version "4.2.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
graphemer@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6"
@@ -1431,15 +1407,6 @@ prettier@3.8.1:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
proper-lockfile@4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f"
integrity sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==
dependencies:
graceful-fs "^4.2.4"
retry "^0.12.0"
signal-exit "^3.0.2"
punycode@^2.1.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
@@ -1455,11 +1422,6 @@ resolve-from@^4.0.0:
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
retry@^0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==
reusify@^1.0.4:
version "1.1.0"
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f"
@@ -1533,11 +1495,6 @@ siginfo@^2.0.0:
resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30"
integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==
signal-exit@^3.0.2:
version "3.0.7"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
signal-exit@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"