One album or file quak cannot decrypt no longer stops a refresh or a backup (closes #181) #188

Merged
clawbot merged 8 commits from issue-181-skip-undecryptable into next 2026-10-14 07:08:58 +02:00
11 changed files with 956 additions and 77 deletions
+42 -12
View File
@@ -128,18 +128,20 @@ matters.
### Exit codes ### Exit codes
| Code | Meaning | | Code | Meaning |
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0` | The backup is complete: every original is at its save path, and no file failed. | | `0` | The backup is complete: every original is at its save path, and no file failed. |
| `1` | The run finished with files in `failures.json`, or it stopped on another error, printed as one line `quak: <message>`. The next run tries again. | | `1` | The run finished with files in `failures.json` or an album it could not decrypt, or it stopped on another error, printed as one line `quak: <message>`. The next run tries again. |
| `2` | Another backup of the same directory is running. This run sent no request and changed nothing. | | `2` | Another backup of the same directory is running. This run sent no request and changed nothing. |
| `3` | There is no usable session: none is saved, the saved one is corrupt, or the server no longer accepts it. Run `quak login` as the user the cron job runs as. | | `3` | There is no usable session: none is saved, the saved one is corrupt, or the server no longer accepts it. Run `quak login` as the user the cron job runs as. |
After a `1`, the next run fetches each missing original again, fetches the ML After a `1`, the next run fetches each missing original again, fetches the ML
data that is not cached, and rebuilds the album links. An original that a data that is not cached, and rebuilds the album links. An album quak could not
`--verify` run could not read, or put back with bytes that still do not match, decrypt, or a file in `failures.json` because quak could not decrypt it, keeps
stays at its save path. Only a later `--verify` run checks it again: a run the exit code at `1` until the server sends a copy that decrypts, or deletes it
without `--verify` leaves it as it is and takes the file out of `failures.json`, (see "Backup layout"). An original that a `--verify` run could not read, or put
so that run can exit 0. back with bytes that still do not match, stays at its save path. Only a later
`--verify` run checks it again: a run without `--verify` leaves it as it is and
takes the file out of `failures.json`, so that run can exit 0.
## Examples ## Examples
@@ -623,8 +625,10 @@ library. The read commands — `collections`, `files`, `get`, `get-thumb`,
answer, so they report current account state rather than whatever the cache last answer, so they report current account state rather than whatever the cache last
held. If that round-trip fails, the command prints the error on one line and held. If that round-trip fails, the command prints the error on one line and
exits 1, or 3 when the server no longer accepts the saved session (see "Session exits 1, or 3 when the server no longer accepts the saved session (see "Session
handling"). `--cache-dir` overrides where the cache lives; without it each handling"). An album or file quak cannot decrypt does not fail it; the command
account gets its own directory under the per-user cache path. answers from what did decrypt (see "Default reads vs. fresh reads").
`--cache-dir` overrides where the cache lives; without it each account gets its
own directory under the per-user cache path.
An ID given to `files --collection`, `get`, `get-thumb` or An ID given to `files --collection`, `get`, `get-thumb` or
`helper fix-missing-thumbnails --file` is written in decimal digits, no larger `helper fix-missing-thumbnails --file` is written in decimal digits, no larger
@@ -741,6 +745,19 @@ succeeds, or once it is no longer in the library or in the backup's scope. The
library's `lib.backup({ includeThumbnails: true })` also writes library's `lib.backup({ includeThumbnails: true })` also writes
`thumbnails/<fileID>.jpg` beside `collections/`; `quak backup` does not. `thumbnails/<fileID>.jpg` beside `collections/`; `quak backup` does not.
A file in the backup's scope that the library could not decrypt (see "Default
reads vs. fresh reads") goes into `failures.json` with an error that starts
`could not be decrypted:` and gives the reason. If a copy of it in another album
decrypted, it does not: its original is stored from that copy, and the album
where it does not decrypt neither links nor lists it. An album the library could
not decrypt has no name quak can read and no files it can list. A run without
`--json` prints one line for it,
`FAILED album <id>: could not be decrypted: <reason>`, and `--json` and the
`BackupResult` list its ID in `undecryptableAlbums`. Such a file in
`failures.json`, or such an album, keeps the exit code at 1, run after run,
until the server sends a copy that decrypts or deletes it; the next run then
drops it.
`backup.lock` keeps two backups of the same directory from running at once, such `backup.lock` keeps two backups of the same directory from running at once, such
as a cron run that starts while the previous one is still going. A backup as a cron run that starts while the previous one is still going. A backup
creates `<dir>` if it is missing and takes the lock before its refresh, and creates `<dir>` if it is missing and takes the lock before its refresh, and
@@ -777,9 +794,12 @@ both, each named after the title with that file's extension (`IMG_0001.heic` and
Each run removes the symlinks into the date folders that no longer belong in Each run removes the symlinks into the date folders that no longer belong in
their collection's directory, and the directories (and JSON) of collections that their collection's directory, and the directories (and JSON) of collections that
were deleted or renamed. Nothing else in `collections/` is touched: a file or a were deleted or renamed. An album quak can no longer decrypt loses its directory
symlink you put there stays, and a directory that still holds one after its and JSON the same way, and a file whose copy in an album quak can no longer
symlinks are removed stays too, with its JSON. decrypt loses its symlink there and its entry in that album's JSON; the
originals stay at their save paths. Nothing else in `collections/` is touched: a
file or a symlink you put there stays, and a directory that still holds one
after its symlinks are removed stays too, with its JSON.
Each file is downloaded exactly once regardless of how many collections it Each file is downloaded exactly once regardless of how many collections it
appears in, and written once: straight to its save path, with no copy left in appears in, and written once: straight to its save path, with no copy left in
@@ -915,6 +935,16 @@ rejects the caller (default reads stay silent and keep serving the last good
copy). The CLI's read commands use fresh reads (issue copy). The CLI's read commands use fresh reads (issue
https://git.eeqj.de/sneak/quak/issues/75). https://git.eeqj.de/sneak/quak/issues/75).
An album or file in the server's answer that quak cannot decrypt, or whose
metadata is not JSON, does not fail the refresh. The refresh leaves it out, an
album with all its files, and drops any older copy the cache held, so reads
answer from what did decrypt. The library keeps a list of what it left out, with
the reason, in `metadata.json`: the next refresh asks the server only for what
changed, so it would not see the record again. A record stays on that list until
a later refresh gets a copy that decrypts or learns that the server deleted it.
`lib.backup()` reports each such album, and each such file in its scope that has
no copy that decrypted (see "Backup layout").
### Read surface ### Read surface
- `lib.albums.list()` → `Album[]`, newest-updated first. - `lib.albums.list()` → `Album[]`, newest-updated first.
+13
View File
@@ -25,6 +25,19 @@ declares one.
# Completed Steps # Completed Steps
- 2026-10-14: An album or file quak cannot decrypt, or whose metadata is not
JSON, no longer fails the refresh, so one bad record no longer stops
`quak backup` from storing anything (issue 181). The refresh leaves it out, an
album with its files, and the library keeps it with the reason in
`metadata.json` until the server sends a copy that decrypts or deletes it.
`quak backup` stores every other file, records each such file in
`failures.json` with an error starting `could not be decrypted:`, names each
such album's ID in a printed line, or with `--json` in `undecryptableAlbums`,
and exits 1 while any remain. A file whose copy in another album decrypts is
not recorded: its original is stored from that copy, and the album where it
does not decrypt neither links nor lists it. The read commands answer from
what decrypted.
- 2026-10-14: `quak backup` no longer asks the server for changes every 3 - 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 seconds while it runs (issue 183). It opens its library with
`refreshIntervalSeconds: Infinity`, which the library now takes as no `refreshIntervalSeconds: Infinity`, which the library now takes as no
+61 -18
View File
@@ -47,6 +47,9 @@
// download, a failed symlink, or ML data missing because the ML data fetch // download, a failed symlink, or ML data missing because the ML data fetch
// failed is caught, recorded in `failures.json` with a classification, a // failed is caught, recorded in `failures.json` with a classification, a
// running attempt count, and the last-tried time, and the run continues. // 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 // `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 // 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 // reconciles the ledger against the files it attempted, so an entry for a file
@@ -86,6 +89,7 @@ import {
storedAtSavePath, storedAtSavePath,
} from "./library/content.js"; } from "./library/content.js";
import { representative } from "./library/records.js"; import { representative } from "./library/records.js";
import type { Undecryptable } from "./library/store.js";
import { extractImageMetadata } from "./metadata-backup.js"; import { extractImageMetadata } from "./metadata-backup.js";
import type { MLData } from "./mldata-fetch.js"; import type { MLData } from "./mldata-fetch.js";
import type { Collection, EnteFile, FileMetadata } from "./model/types.js"; import type { Collection, EnteFile, FileMetadata } from "./model/types.js";
@@ -141,6 +145,9 @@ export interface BackupResult {
failed: number; failed: number;
// This run's per-file errors, in encounter order. // This run's per-file errors, in encounter order.
errors: BackupError[]; errors: BackupError[];
// The IDs of the albums the library could not decrypt, whose files it
// could not list; the CLI exits non-zero while there are any.
undecryptableAlbums: number[];
} }
// The slice of the library that backup drives. `Library` implements it; a test // The slice of the library that backup drives. `Library` implements it; a test
@@ -151,6 +158,9 @@ export interface BackupLibrary {
refresh(): Promise<void>; refresh(): Promise<void>;
listCollections(): Collection[]; listCollections(): Collection[];
listFiles(collectionID: number): EnteFile[]; listFiles(collectionID: number): EnteFile[];
// The albums, and files in albums, that the library could not decrypt,
// which `listCollections` and `listFiles` leave out.
undecryptable(): Undecryptable[];
// Get an original's bytes onto disk through the content cache/pools, // Get an original's bytes onto disk through the content cache/pools,
// returning where they landed: `destination` when they were fetched now, // returning where they landed: `destination` when they were fetched now,
// otherwise wherever they already were (the cache, or the library's save // otherwise wherever they already were (the cache, or the library's save
@@ -574,33 +584,56 @@ const runLockedBackup = async (
let unchecked = 0; let unchecked = 0;
const recordFailure = ( const recordFailure = (
file: EnteFile, fileID: number,
title: string,
collection: string, collection: string,
err: unknown, err: unknown,
): void => { ): void => {
// Count at most one attempt per file per run: a file whose original // Count at most one attempt per file per run: a file whose original
// and thumbnail both fail this run must not double its attempt count // and thumbnail both fail this run must not double its attempt count
// or appear twice in errors. // or appear twice in errors.
if (failedThisRun.has(file.id)) return; if (failedThisRun.has(fileID)) return;
const error = errorMessage(err); const error = errorMessage(err);
errors.push({ errors.push({ fileID, title, collection, error });
fileID: file.id, const prior = ledger.get(fileID);
title: file.metadata.title, ledger.set(fileID, {
collection, fileID,
error, title,
});
const prior = ledger.get(file.id);
ledger.set(file.id, {
fileID: file.id,
title: file.metadata.title,
classification: classify(err), classification: classify(err),
attempts: (prior?.attempts ?? 0) + 1, attempts: (prior?.attempts ?? 0) + 1,
lastTriedAt: now, lastTriedAt: now,
error, error,
}); });
failedThisRun.add(file.id); failedThisRun.add(fileID);
}; };
// What the library could not decrypt. An album is logged by its ID, as
// its name is not known, so a run limited to some albums logs it too. A
// file in an album in scope is backed up from a copy of it in another
// album that decrypted, even an album out of scope. With no such copy it
// is recorded as failed, with an empty title as its metadata is not known.
const scope = new Set(collections.map((c) => c.id));
const undecryptableAlbums: number[] = [];
for (const record of lib.undecryptable()) {
const error = `could not be decrypted: ${record.error}`;
if (record.fileID === undefined) {
log(`FAILED album ${record.collectionID}: ${error}`);
undecryptableAlbums.push(record.collectionID);
} else if (scope.has(record.collectionID)) {
const copies = memberships.get(record.fileID);
if (copies) {
distinct.set(record.fileID, representative(copies));
} else {
recordFailure(
record.fileID,
"",
collectionName.get(record.collectionID) ?? "",
new Error(error),
);
}
}
}
// Phase 1: get the bytes. Put each pending original at its save path // Phase 1: get the bytes. Put each pending original at its save path
// through the content cache/pools, as `Photo.download()` does, and fetch // through the content cache/pools, as `Photo.download()` does, and fetch
// the optional thumbnails; a present file is left as is. With `verify`, a // the optional thumbnails; a present file is left as is. With `verify`, a
@@ -636,7 +669,8 @@ const runLockedBackup = async (
`FAILED verifying original ${file.metadata.title}: ${errorMessage(err)}`, `FAILED verifying original ${file.metadata.title}: ${errorMessage(err)}`,
); );
recordFailure( recordFailure(
file, file.id,
file.metadata.title,
collectionName.get(file.collectionID) ?? "", collectionName.get(file.collectionID) ?? "",
err, err,
); );
@@ -676,7 +710,8 @@ const runLockedBackup = async (
`FAILED original ${file.metadata.title}: ${errorMessage(err)}`, `FAILED original ${file.metadata.title}: ${errorMessage(err)}`,
); );
recordFailure( recordFailure(
file, file.id,
file.metadata.title,
collectionName.get(file.collectionID) ?? "", collectionName.get(file.collectionID) ?? "",
err, err,
); );
@@ -693,7 +728,8 @@ const runLockedBackup = async (
await copyAtomic(path, dest); await copyAtomic(path, dest);
} catch (err) { } catch (err) {
recordFailure( recordFailure(
file, file.id,
file.metadata.title,
collectionName.get(file.collectionID) ?? "", collectionName.get(file.collectionID) ?? "",
err, err,
); );
@@ -733,7 +769,8 @@ const runLockedBackup = async (
if (mlData === undefined && mlDataError !== undefined) { if (mlData === undefined && mlDataError !== undefined) {
writeSidecar(path, file, { mlDataError }, image); writeSidecar(path, file, { mlDataError }, image);
recordFailure( recordFailure(
file, file.id,
file.metadata.title,
collectionName.get(file.collectionID) ?? "", collectionName.get(file.collectionID) ?? "",
new Error(`ML data: ${mlDataError}`), new Error(`ML data: ${mlDataError}`),
); );
@@ -806,7 +843,12 @@ const runLockedBackup = async (
log( log(
`FAILED symlink ${c.name}/${linkName}: ${errorMessage(err)}`, `FAILED symlink ${c.name}/${linkName}: ${errorMessage(err)}`,
); );
recordFailure(link.file, c.name, err); recordFailure(
link.file.id,
link.file.metadata.title,
c.name,
err,
);
} }
} }
@@ -837,6 +879,7 @@ const runLockedBackup = async (
unchecked, unchecked,
failed: ledger.size, failed: ledger.size,
errors, errors,
undecryptableAlbums,
}; };
}; };
+3 -1
View File
@@ -470,7 +470,9 @@ export const backupCommand = async (
} }
} }
return result.failed > 0 ? 1 : 0; return result.failed > 0 || result.undecryptableAlbums.length > 0
? 1
: 0;
} finally { } finally {
await lib.close(); await lib.close();
} }
+41 -14
View File
@@ -44,20 +44,28 @@ export interface ClientSnapshot {
// The result of a resumable enumeration. Live decrypted records and deleted // The result of a resumable enumeration. Live decrypted records and deleted
// ids are kept apart on purpose: a tombstone carries no key or metadata to // ids are kept apart on purpose: a tombstone carries no key or metadata to
// decrypt, so it is a bare id rather than a hollowed-out record. `cursor` is // decrypt, so it is a bare id rather than a hollowed-out record. A live row
// the max `updationTime` seen, to pass back into the next call. // that cannot be decrypted, or whose metadata cannot be parsed, is in
// `undecryptable` with the reason instead, so one bad row does not fail the
// call; absent means there was none. `cursor` is the max `updationTime` seen,
// those rows included, to pass back into the next call.
export interface CollectionsPage { export interface CollectionsPage {
collections: Collection[]; collections: Collection[];
deleted: number[]; deleted: number[];
undecryptable?: { id: number; error: string }[];
cursor: number; cursor: number;
} }
export interface FilesPage { export interface FilesPage {
files: EnteFile[]; files: EnteFile[];
deleted: number[]; deleted: number[];
undecryptable?: { id: number; error: string }[];
cursor: number; cursor: number;
} }
const errorMessage = (err: unknown): string =>
err instanceof Error ? err.message : String(err);
export class Client { export class Client {
private readonly api: ApiClient; private readonly api: ApiClient;
private readonly email: string; private readonly email: string;
@@ -237,11 +245,12 @@ export class Client {
} }
// Enumerate collections changed since `sinceTime`. Live collections are // Enumerate collections changed since `sinceTime`. Live collections are
// decrypted; tombstoned ones (isDeleted) are surfaced as bare ids. The // decrypted; tombstoned ones (isDeleted) are surfaced as bare ids, and
// returned cursor is the max `updationTime` seen — including tombstones, so // ones that do not decrypt as ids with the reason. The returned cursor is
// the next sync resumes past them — falling back to `sinceTime` when the // the max `updationTime` seen — including tombstones, so the next sync
// response is empty. `/collections/v2` returns the whole changed set in one // resumes past them — falling back to `sinceTime` when the response is
// response, so there is no pagination here. // empty. `/collections/v2` returns the whole changed set in one response,
// so there is no pagination here.
async collectionsSince(args: { async collectionsSince(args: {
sinceTime: number; sinceTime: number;
}): Promise<CollectionsPage> { }): Promise<CollectionsPage> {
@@ -254,11 +263,13 @@ export class Client {
const collections: Collection[] = []; const collections: Collection[] = [];
const deleted: number[] = []; const deleted: number[] = [];
const undecryptable: { id: number; error: string }[] = [];
let cursor = args.sinceTime; let cursor = args.sinceTime;
for (const raw of raws) { for (const raw of raws) {
if (raw.isDeleted) { if (raw.isDeleted) {
deleted.push(raw.id); deleted.push(raw.id);
} else { } else {
try {
collections.push( collections.push(
decryptCollection( decryptCollection(
raw, raw,
@@ -270,15 +281,22 @@ export class Client {
this.userID, this.userID,
), ),
); );
} catch (err) {
undecryptable.push({
id: raw.id,
error: errorMessage(err),
});
}
} }
if (raw.updationTime > cursor) cursor = raw.updationTime; if (raw.updationTime > cursor) cursor = raw.updationTime;
} }
return { collections, deleted, cursor }; return { collections, deleted, undecryptable, cursor };
} }
// Enumerate a collection's files changed since `sinceTime`, paginating the // Enumerate a collection's files changed since `sinceTime`, paginating the
// diff from that cursor. Live rows are decrypted; tombstoned ones are // diff from that cursor. Live rows are decrypted; tombstoned ones are
// surfaced as bare ids. Returns the final cursor to resume from. // surfaced as bare ids, and ones that do not decrypt or parse as ids with
// the reason. Returns the final cursor to resume from.
async filesSince(args: { async filesSince(args: {
collectionID: number; collectionID: number;
collectionKey: Uint8Array; collectionKey: Uint8Array;
@@ -288,6 +306,7 @@ export class Client {
const { collectionID, collectionKey } = args; const { collectionID, collectionKey } = args;
const files: EnteFile[] = []; const files: EnteFile[] = [];
const deleted: number[] = []; const deleted: number[] = [];
const undecryptable: { id: number; error: string }[] = [];
let cursor = args.sinceTime; let cursor = args.sinceTime;
for (;;) { for (;;) {
const { diff, hasMore } = await this.api.getJSON<{ const { diff, hasMore } = await this.api.getJSON<{
@@ -300,7 +319,14 @@ export class Client {
if (raw.isDeleted) { if (raw.isDeleted) {
deleted.push(raw.id); deleted.push(raw.id);
} else { } else {
try {
files.push(decryptFile(raw, collectionKey)); files.push(decryptFile(raw, collectionKey));
} catch (err) {
undecryptable.push({
id: raw.id,
error: errorMessage(err),
});
}
} }
if (raw.updationTime > pageMax) pageMax = raw.updationTime; if (raw.updationTime > pageMax) pageMax = raw.updationTime;
} }
@@ -321,18 +347,19 @@ export class Client {
} }
cursor = pageMax; cursor = pageMax;
} }
return { files, deleted, cursor }; return { files, deleted, undecryptable, cursor };
} }
// Whole-account listing: every live collection, deletions hidden. A thin // Whole-account listing: every live collection that decrypts, deletions
// wrapper over `collectionsSince` from the beginning of time. // hidden. A thin wrapper over `collectionsSince` from the beginning of
// time.
async listCollections(): Promise<Collection[]> { async listCollections(): Promise<Collection[]> {
const { collections } = await this.collectionsSince({ sinceTime: 0 }); const { collections } = await this.collectionsSince({ sinceTime: 0 });
return collections; return collections;
} }
// Every live file in a collection, deletions hidden. A thin wrapper over // Every live file in a collection that decrypts, deletions hidden. A thin
// `filesSince` from the beginning of time. // wrapper over `filesSince` from the beginning of time.
async listFiles( async listFiles(
collectionID: number, collectionID: number,
collectionKey: Uint8Array, collectionKey: Uint8Array,
+26 -1
View File
@@ -601,6 +601,7 @@ export class Library {
refresh: () => this.refreshNow(), refresh: () => this.refreshNow(),
listCollections: () => this.store.listCollections(), listCollections: () => this.store.listCollections(),
listFiles: (id) => this.store.listFiles(id), listFiles: (id) => this.store.listFiles(id),
undecryptable: () => this.store.listUndecryptable(),
original: (fileID, destination) => original: (fileID, destination) =>
cache!.backupOriginal(fileID, destination), cache!.backupOriginal(fileID, destination),
thumbnail: (fileID) => cache!.thumbnail(fileID), thumbnail: (fileID) => cache!.thumbnail(fileID),
@@ -703,7 +704,10 @@ export class Library {
// Fetch every change since the stored cursor, then commit. All network // Fetch every change since the stored cursor, then commit. All network
// reads happen before any store mutation, so a fetch that throws leaves the // reads happen before any store mutation, so a fetch that throws leaves the
// store untouched and the previous snapshot intact. // store untouched and the previous snapshot intact. A collection or file
// that does not decrypt is left out instead, as if deleted, with the reason
// kept in the store: the cursor moves past it, so no later refresh sees it
// again until the server's copy changes.
private async refreshOnce(): Promise<void> { private async refreshOnce(): Promise<void> {
const page = await this.client.collectionsSince({ const page = await this.client.collectionsSince({
sinceTime: this.store.collectionsSinceTime, sinceTime: this.store.collectionsSinceTime,
@@ -740,9 +744,17 @@ export class Library {
this.store.deleteCollection(id); this.store.deleteCollection(id);
changed = true; changed = true;
} }
if (this.store.deleteUndecryptable(id)) changed = true;
}
// A collection left out takes its files with it.
for (const { id, error } of page.undecryptable ?? []) {
this.store.deleteCollection(id);
this.store.putUndecryptable({ collectionID: id, error });
changed = true;
} }
for (const collection of page.collections) { for (const collection of page.collections) {
this.store.putCollection(collection); this.store.putCollection(collection);
this.store.deleteUndecryptable(collection.id);
changed = true; changed = true;
} }
@@ -752,9 +764,22 @@ export class Library {
this.store.deleteFile(collectionID, id); this.store.deleteFile(collectionID, id);
changed = true; changed = true;
} }
if (this.store.deleteUndecryptable(collectionID, id)) {
changed = true;
}
}
for (const { id, error } of filePage.undecryptable ?? []) {
this.store.deleteFile(collectionID, id);
this.store.putUndecryptable({
collectionID,
fileID: id,
error,
});
changed = true;
} }
for (const f of filePage.files) { for (const f of filePage.files) {
this.store.putFile(f); this.store.putFile(f);
this.store.deleteUndecryptable(collectionID, f.id);
changed = true; changed = true;
} }
} }
+50 -5
View File
@@ -2,10 +2,11 @@
// //
// The store keeps one `metadata.json` file holding the account's server // The store keeps one `metadata.json` file holding the account's server
// state: the user id, a schema version, the cursor for the incremental // state: the user id, a schema version, the cursor for the incremental
// collections listing, and the decrypted collection and file records. The // collections listing, the decrypted collection and file records, and the
// whole file is read into RAM on load and rewritten as a whole on save; there // collections and files the refresh could not decrypt. The whole file is read
// is no partial update and no lock file. A separate refresh unit populates the // into RAM on load and rewritten as a whole on save; there is no partial
// store from the server — this module only stores what it is given. // update and no lock file. A separate refresh unit populates the store from
// the server — this module only stores what it is given.
// //
// The file is a cache, so it is never trusted to exist or to be intact: a // The file is a cache, so it is never trusted to exist or to be intact: a
// missing or unreadable file loads as an empty store rather than an error, and // missing or unreadable file loads as an empty store rather than an error, and
@@ -33,12 +34,22 @@ const FILE_MODE = 0o600;
type StoredCollection = Omit<Collection, "key"> & { key: string }; type StoredCollection = Omit<Collection, "key"> & { key: string };
type StoredFile = Omit<EnteFile, "key"> & { key: string }; type StoredFile = Omit<EnteFile, "key"> & { key: string };
// A collection, or with `fileID` a file in it, that the last refresh to fetch
// it could not decrypt or parse, and why. It is kept until a later refresh
// fetches it again, because a refresh fetches only what changed.
export interface Undecryptable {
collectionID: number;
fileID?: number;
error: string;
}
interface StoredMetadata { interface StoredMetadata {
schemaVersion: number; schemaVersion: number;
userID: number; userID: number;
collectionsSinceTime: Microseconds; collectionsSinceTime: Microseconds;
collections: StoredCollection[]; collections: StoredCollection[];
files: StoredFile[]; files: StoredFile[];
undecryptable: Undecryptable[];
} }
const encodeKey = (key: Uint8Array): string => const encodeKey = (key: Uint8Array): string =>
@@ -67,6 +78,7 @@ export class MetadataStore {
private readonly collections = new Map<number, Collection>(); private readonly collections = new Map<number, Collection>();
private readonly files = new Map<string, EnteFile>(); private readonly files = new Map<string, EnteFile>();
private undecryptable: Undecryptable[] = [];
private constructor(path: string) { private constructor(path: string) {
this.path = path; this.path = path;
@@ -107,6 +119,12 @@ export class MetadataStore {
}; };
store.files.set(fileKey(file.collectionID, file.id), file); 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 { } catch {
// Any corruption discards the partial result: a half-read cache is // Any corruption discards the partial result: a half-read cache is
// worse than an empty one, since the refresh unit will rebuild it. // worse than an empty one, since the refresh unit will rebuild it.
@@ -134,6 +152,7 @@ export class MetadataStore {
...f, ...f,
key: encodeKey(f.key), key: encodeKey(f.key),
})), })),
undecryptable: this.undecryptable,
}; };
const dir = dirname(this.path); const dir = dirname(this.path);
@@ -165,7 +184,8 @@ export class MetadataStore {
this.collections.set(collection.id, collection); this.collections.set(collection.id, collection);
} }
// Removing a collection also drops its file memberships: a file record is // Removing a collection also drops its file memberships, and any record
// that it or one of its files could not be decrypted: a file record is
// only meaningful as part of a collection the cache still knows about. // only meaningful as part of a collection the cache still knows about.
deleteCollection(id: number): void { deleteCollection(id: number): void {
this.collections.delete(id); this.collections.delete(id);
@@ -174,6 +194,9 @@ export class MetadataStore {
this.files.delete(key); this.files.delete(key);
} }
} }
this.undecryptable = this.undecryptable.filter(
(record) => record.collectionID !== id,
);
} }
getFile(collectionID: number, fileID: number): EnteFile | undefined { getFile(collectionID: number, fileID: number): EnteFile | undefined {
@@ -205,4 +228,26 @@ export class MetadataStore {
deleteFile(collectionID: number, fileID: number): void { deleteFile(collectionID: number, fileID: number): void {
this.files.delete(fileKey(collectionID, fileID)); this.files.delete(fileKey(collectionID, fileID));
} }
listUndecryptable(): Undecryptable[] {
return [...this.undecryptable];
}
// Replaces any earlier record of the same collection or file.
putUndecryptable(record: Undecryptable): void {
this.deleteUndecryptable(record.collectionID, record.fileID);
this.undecryptable.push(record);
}
// Forgets the record of the collection, or with `fileID` of that file in
// it. Returns whether there was one.
deleteUndecryptable(collectionID: number, fileID?: number): boolean {
const before = this.undecryptable.length;
this.undecryptable = this.undecryptable.filter(
(record) =>
record.collectionID !== collectionID ||
record.fileID !== fileID,
);
return this.undecryptable.length < before;
}
} }
+554
View File
@@ -0,0 +1,554 @@
/**
* `quak backup` over an account holding an album or a file quak cannot decrypt
* (issue 181).
*
* The server sends every album and file encrypted, and a refresh decrypts each
* one it fetches. A record whose key no key of the account opens, or whose
* metadata is not JSON, must not fail the refresh: a refresh that fails commits
* nothing, so `quak backup` would store nothing, on every run, until the
* server's copy changed. Instead the refresh leaves that record out, and the
* library keeps a list of what it left out in its cache. It has to: the next
* refresh asks the server only for what changed since the last one, so it will
* not see the record again.
*
* The backup stores every other file. It records each file it could not
* decrypt in `failures.json`, prints one line for each album it could not
* decrypt, and exits 1 while any remain. Once the server sends a copy that
* decrypts, or deletes the record, the next run drops it and can exit 0. The
* read commands answer from what did decrypt.
*
* These tests run the commands with a real saved session over a stand-in Ente
* server, which serves albums, files and originals encrypted with libsodium as
* the real server does. A record is broken the way a corrupt one arrives: its
* key replaced by bytes no key opens, or its metadata text that is not JSON.
*/
import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import sodium from "libsodium-wrappers-sumo";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
backupCommand,
collectionsCommand,
filesCommand,
saveSession,
type CliContext,
} from "../../src/cli-commands.js";
import { loadSession } from "../../src/cli-session.js";
import { Client, type ClientSnapshot } from "../../src/client.js";
import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js";
import { Library } from "../../src/library/index.js";
const USER_ID = 42;
// Every file is taken at noon local time on 2026-03-01, in microseconds as Ente
// stores times, so its original is saved in the folder `DAY` whatever the time
// zone of the machine running the tests.
const TAKEN = new Date(2026, 2, 1, 12).getTime() * 1000;
const DAY = join("2026", "2026-03", "2026-03-01");
type Row = { id: number; updationTime: number } & Record<string, unknown>;
// `plaintext` sealed under `key` with a fresh nonce, both base64, as Ente
// sends a key or an album name.
const secretbox = (
plaintext: Uint8Array,
key: Uint8Array,
): { ciphertext: string; nonce: string } => {
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
return {
ciphertext: toBase64(
sodium.crypto_secretbox_easy(plaintext, nonce, key),
),
nonce: toBase64(nonce),
};
};
// What a corrupt row carries in place of its sealed key: random bytes of the
// same length, which no key opens.
const corruptKey = (): string =>
toBase64(
sodium.randombytes_buf(
sodium.crypto_secretbox_KEYBYTES + sodium.crypto_secretbox_MACBYTES,
),
);
const json = (body: unknown): Response =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
// A stand-in Ente server for one account. It holds each album, and each file in
// an album, as the row `/collections/v2` and `/collections/v2/diff` serve, and
// answers a request with the rows whose `updationTime` is past the request's
// `sinceTime`, as the real server does. A change to a file also moves its
// album's `updationTime`, which is what tells a client to list the album's
// files again. Originals are served by file ID, as the files CDN serves them.
class FakeEnte {
private readonly masterKey = sodium.crypto_secretbox_keygen();
private readonly keyPair = sodium.crypto_box_keypair();
private readonly albums = new Map<number, Row>();
private readonly albumKeys = new Map<number, Uint8Array>();
// Each album's file rows, by album ID and then file ID.
private readonly files = new Map<number, Map<number, Row>>();
// Each file's key and its original as served, by file ID. A file in two
// albums is one original under one key, sealed under each album's key.
private readonly originals = new Map<
number,
{ key: Uint8Array; header: string; body: Uint8Array }
>();
// The session `quak login` would have saved for the account.
snapshot(): ClientSnapshot {
return {
email: "user@example.com",
userID: USER_ID,
token: "token",
masterKey: toBase64(this.masterKey),
secretKey: toBase64(this.keyPair.privateKey),
publicKey: toBase64(this.keyPair.publicKey),
};
}
// Album `id`, named `name`, as of time `t`. With `corrupt`, its key cannot
// be decrypted. An album keeps its key from one call to the next, so its
// files' rows still decrypt once it is put back without `corrupt`.
putAlbum(id: number, name: string, t: number, corrupt = false): void {
const key = this.albumKeys.get(id) ?? sodium.crypto_secretbox_keygen();
this.albumKeys.set(id, key);
const sealedKey = secretbox(key, this.masterKey);
const sealedName = secretbox(new TextEncoder().encode(name), key);
this.albums.set(id, {
id,
owner: { id: USER_ID },
encryptedKey: corrupt ? corruptKey() : sealedKey.ciphertext,
keyDecryptionNonce: sealedKey.nonce,
encryptedName: sealedName.ciphertext,
nameDecryptionNonce: sealedName.nonce,
type: "album",
updationTime: t,
});
if (!this.files.has(id)) this.files.set(id, new Map());
}
// File `id`, titled `photo-<id>.jpg`, in album `albumID` as of time `t`.
// With `corruptKey`, its key cannot be decrypted. With `metadata`, its
// metadata decrypts to that text instead of the JSON a client uploads.
putFile(
albumID: number,
id: number,
t: number,
opts: { corruptKey?: boolean; metadata?: string } = {},
): void {
const original = this.originalOf(id);
const sealedKey = secretbox(original.key, this.albumKeys.get(albumID)!);
const metadata =
opts.metadata ??
JSON.stringify({
title: `photo-${id}.jpg`,
fileType: 0,
creationTime: TAKEN,
modificationTime: TAKEN,
});
const sealedMetadata = encryptBlob(
new TextEncoder().encode(metadata),
original.key,
);
this.files.get(albumID)!.set(id, {
id,
collectionID: albumID,
ownerID: USER_ID,
encryptedKey: opts.corruptKey ? corruptKey() : sealedKey.ciphertext,
keyDecryptionNonce: sealedKey.nonce,
metadata: {
encryptedData: toBase64(sealedMetadata.ciphertext),
decryptionHeader: toBase64(sealedMetadata.header),
},
file: { decryptionHeader: original.header },
thumbnail: { decryptionHeader: original.header },
updationTime: t,
});
this.moveAlbum(albumID, t);
}
// A deleted record stays on the server as a tombstone holding only its
// ID, its time and `isDeleted`.
deleteFile(albumID: number, id: number, t: number): void {
this.files
.get(albumID)!
.set(id, { id, updationTime: t, isDeleted: true });
this.moveAlbum(albumID, t);
}
deleteAlbum(id: number, t: number): void {
this.albums.set(id, { id, updationTime: t, isDeleted: true });
}
// The `fetch` the saved session's client is given.
readonly fetch = (async (input: string | URL | Request) => {
const url = new URL(input instanceof Request ? input.url : input);
const since = Number(url.searchParams.get("sinceTime"));
const changed = (rows: Iterable<Row>): Row[] =>
[...rows].filter((row) => row.updationTime > since);
switch (url.pathname) {
case "/collections/v2":
return json({ collections: changed(this.albums.values()) });
case "/collections/v2/diff": {
const albumID = Number(url.searchParams.get("collectionID"));
const rows = this.files.get(albumID)?.values() ?? [];
return json({ diff: changed(rows), hasMore: false });
}
case "/files/data/fetch":
// No file has ML data.
return json({ data: [] });
default: {
const fileID = Number(url.searchParams.get("fileID"));
const original = this.originals.get(fileID);
return original
? new Response(original.body)
: new Response("not found", { status: 404 });
}
}
}) as typeof globalThis.fetch;
private originalOf(id: number): {
key: Uint8Array;
header: string;
body: Uint8Array;
} {
const known = this.originals.get(id);
if (known) return known;
const key = sodium.crypto_secretbox_keygen();
const { header, ciphertext } = encryptBlob(
new TextEncoder().encode(`original of ${id}`),
key,
);
const original = { key, header: toBase64(header), body: ciphertext };
this.originals.set(id, original);
return original;
}
private moveAlbum(albumID: number, t: number): void {
this.albums.set(albumID, {
...this.albums.get(albumID)!,
updationTime: t,
});
}
}
// Album 1, "Trip", holds file 10, which decrypts, file 11, whose key does not,
// and file 12, whose metadata is not JSON. Album 2, "Family", holds file 20,
// but the album's own key does not decrypt. All of it as of time 1.
const brokenAccount = (): FakeEnte => {
const ente = new FakeEnte();
ente.putAlbum(1, "Trip", 1);
ente.putFile(1, 10, 1);
ente.putFile(1, 11, 1, { corruptKey: true });
ente.putFile(1, 12, 1, { metadata: "not json" });
ente.putAlbum(2, "Family", 1, true);
ente.putFile(2, 20, 1);
return ente;
};
// Album 1, "Trip", holds file 10, and album 2, "Family", holds file 20. Both
// files decrypt, but album 2's own key does not, so the album is the only
// record left out. All of it as of time 1.
const brokenAlbumAccount = (): FakeEnte => {
const ente = new FakeEnte();
ente.putAlbum(1, "Trip", 1);
ente.putFile(1, 10, 1);
ente.putAlbum(2, "Family", 1, true);
ente.putFile(2, 20, 1);
return ente;
};
// Collects everything written to it.
class Output {
text = "";
write(text: string): void {
this.text += text;
}
}
let root: string;
let stdout: Output;
let stderr: Output;
beforeAll(async () => {
await init();
await sodium.ready;
});
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "quak-undecryptable-"));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
// A context for one command, with fresh output, whose saved session talks to
// `ente`. Every run shares one cache directory, as runs of `quak` do.
const context = (ente: FakeEnte): CliContext => {
stdout = new Output();
stderr = new Output();
saveSession(join(root, "session"), ente.snapshot());
return {
stdout,
stderr,
sessionDir: join(root, "session"),
cacheDir: join(root, "cache"),
loadSession: (path) =>
loadSession(path, { fetch: ente.fetch, retry: { attempts: 1 } }),
login: async () => {
throw new Error("login not expected");
},
prompt: async () => {
throw new Error("prompt not expected");
},
promptSecret: async () => {
throw new Error("prompt not expected");
},
};
};
const backupDir = (): string => join(root, "backup");
// Runs `quak backup` into the backup directory and returns its exit code.
const backup = (ente: FakeEnte, opts: { json?: boolean } = {}) =>
backupCommand(context(ente), backupDir(), opts);
// The backup's `failures.json` entries by file ID, or {} when it has none.
const failures = (): Record<string, { error: string; attempts: number }> => {
const path = join(backupDir(), "failures.json");
return existsSync(path)
? JSON.parse(readFileSync(path, "utf-8")).files
: {};
};
// Whether file `id`'s original is at its save path in the backup.
const stored = (id: number): boolean =>
existsSync(join(backupDir(), DAY, `2026-03-01.${id}.jpg`));
// The IDs of the files that album `name`'s JSON lists.
const albumFiles = (name: string): number[] =>
JSON.parse(
readFileSync(join(backupDir(), "collections", `${name}.json`), "utf-8"),
).files.map((f: { id: number }) => f.id);
describe("quak backup with records it cannot decrypt", () => {
it("backs up every other file, records each file it cannot decrypt in failures.json, names each such album, and exits 1", async () => {
expect(await backup(brokenAccount())).toBe(1);
expect(stored(10)).toBe(true);
// Without its album's key, file 20 cannot even be listed.
expect(stored(20)).toBe(false);
expect(failures()).toEqual({
"11": expect.objectContaining({
fileID: 11,
error: expect.stringMatching(
/^could not be decrypted: .*wrong secret key/,
),
}),
"12": expect.objectContaining({
fileID: 12,
error: expect.stringMatching(/^could not be decrypted: .*JSON/),
}),
});
expect(stderr.text).toMatch(
/^FAILED album 2: could not be decrypted: .*wrong secret key/m,
);
expect(stderr.text).toContain(" Failed: 2\n");
});
it("names each album it cannot decrypt in the --json result", async () => {
expect(await backup(brokenAccount(), { json: true })).toBe(1);
expect(JSON.parse(stdout.text)).toMatchObject({
downloaded: 1,
failed: 2,
undecryptableAlbums: [2],
});
});
it("still reports them on the next run, though the server does not send them again", async () => {
const ente = brokenAccount();
await backup(ente);
// Nothing changed on the server, so this run's refresh gets no rows at
// all. What it reports, it remembered from the first run.
expect(await backup(ente)).toBe(1);
expect(Object.keys(failures())).toEqual(["11", "12"]);
expect(failures()["11"]!.attempts).toBe(2);
expect(stderr.text).toMatch(/^FAILED album 2: could not be decrypted/m);
});
it("drops each once the server sends a copy that decrypts, and then exits 0", async () => {
const ente = brokenAccount();
await backup(ente);
// At time 2 files 11 and 12 and album 2 change on the server, and now
// each decrypts. Album 2 brings file 20 with it.
ente.putFile(1, 11, 2);
ente.putFile(1, 12, 2);
ente.putAlbum(2, "Family", 2);
expect(await backup(ente)).toBe(0);
expect([11, 12, 20].map(stored)).toEqual([true, true, true]);
expect(existsSync(join(backupDir(), "failures.json"))).toBe(false);
expect(stderr.text).not.toContain("FAILED");
});
it("drops each once the server deletes it, and then exits 0", async () => {
const ente = brokenAccount();
await backup(ente);
ente.deleteFile(1, 11, 2);
ente.deleteFile(1, 12, 2);
ente.deleteAlbum(2, 2);
expect(await backup(ente)).toBe(0);
expect(existsSync(join(backupDir(), "failures.json"))).toBe(false);
expect(stderr.text).not.toContain("FAILED");
});
it("exits 1 when the only record it cannot decrypt is an album, and 0 once the album decrypts", async () => {
const ente = brokenAlbumAccount();
// No file is in `failures.json`: the album alone keeps the exit code
// at 1.
expect(await backup(ente)).toBe(1);
expect(failures()).toEqual({});
ente.putAlbum(2, "Family", 2);
expect(await backup(ente)).toBe(0);
expect(stored(20)).toBe(true);
});
it("exits 1 when the only record it cannot decrypt is an album, and 0 once the album is deleted", async () => {
const ente = brokenAlbumAccount();
expect(await backup(ente)).toBe(1);
expect(failures()).toEqual({});
ente.deleteAlbum(2, 2);
expect(await backup(ente)).toBe(0);
});
it("removes what an earlier run linked for an album or a file that no longer decrypts, and keeps the originals", async () => {
// At time 1 everything decrypts: the first run links files 10 and 11
// in Trip and file 20 in Family.
const ente = new FakeEnte();
ente.putAlbum(1, "Trip", 1);
ente.putFile(1, 10, 1);
ente.putFile(1, 11, 1);
ente.putAlbum(2, "Family", 1);
ente.putFile(2, 20, 1);
expect(await backup(ente)).toBe(0);
const collections = join(backupDir(), "collections");
expect(readdirSync(join(collections, "Trip")).sort()).toEqual([
"photo-10.jpg",
"photo-11.jpg",
]);
expect(readdirSync(join(collections, "Family"))).toEqual([
"photo-20.jpg",
]);
// At time 2 the server's copy of file 11 in Trip, and album 2 itself,
// no longer decrypt. A run builds the album folders, their JSON and
// their links from what the library holds, so it removes them as if
// the album and the file had been deleted: Family's folder and JSON
// go, and Trip neither links nor lists file 11. The originals stay at
// their save paths, and the run exits 1.
ente.putFile(1, 11, 2, { corruptKey: true });
ente.putAlbum(2, "Family", 2, true);
expect(await backup(ente)).toBe(1);
expect(readdirSync(collections).sort()).toEqual(["Trip", "Trip.json"]);
expect(readdirSync(join(collections, "Trip"))).toEqual([
"photo-10.jpg",
]);
expect(albumFiles("Trip")).toEqual([10]);
expect([10, 11, 20].map(stored)).toEqual([true, true, true]);
expect(Object.keys(failures())).toEqual(["11"]);
});
it("backs up a file that does not decrypt in one album but does in another, and does not report it", async () => {
// File 30 is in albums 1 and 3, each holding its key sealed under the
// album's own key. In album 1 that is corrupt; in album 3 it is not,
// and one copy that decrypts is all the backup needs.
const ente = new FakeEnte();
ente.putAlbum(1, "Trip", 1);
ente.putFile(1, 30, 1, { corruptKey: true });
ente.putAlbum(3, "Favorites", 1);
ente.putFile(3, 30, 1);
expect(await backup(ente)).toBe(0);
expect(stored(30)).toBe(true);
expect(failures()).toEqual({});
// Album 1 has no copy of file 30 that decrypts, so it neither links
// the original nor lists the file; album 3 lists it.
expect(readdirSync(join(backupDir(), "collections", "Trip"))).toEqual(
[],
);
expect(albumFiles("Trip")).toEqual([]);
expect(albumFiles("Favorites")).toEqual([30]);
});
it("backs up that file in a run limited to the album where it does not decrypt", async () => {
// The same account, backed up by `lib.backup()` limited to album 1,
// "Trip", as `quak backup` cannot be. File 30 is in Trip, so the run
// must store it, from its copy in album 3 that the run otherwise
// skips.
const ente = new FakeEnte();
ente.putAlbum(1, "Trip", 1);
ente.putFile(1, 30, 1, { corruptKey: true });
ente.putAlbum(3, "Favorites", 1);
ente.putFile(3, 30, 1);
const lib = await Library.open({
client: Client.fromJSON(ente.snapshot(), {
fetch: ente.fetch,
retry: { attempts: 1 },
}),
downloadDirectory: backupDir(),
cacheDirectory: join(root, "cache"),
precacheThumbnails: false,
precacheOriginals: false,
});
try {
const result = await lib.backup({
downloadDirectory: backupDir(),
onlyAlbumNames: ["Trip"],
});
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
} finally {
await lib.close();
}
expect(stored(30)).toBe(true);
expect(failures()).toEqual({});
});
});
describe("the read commands with records quak cannot decrypt", () => {
it("quak collections and quak files answer from what decrypts", async () => {
const ente = brokenAccount();
expect(await collectionsCommand(context(ente), {})).toBe(0);
expect(stdout.text).toBe("1\talbum\tTrip\n");
expect(await filesCommand(context(ente), { collection: "1" })).toBe(0);
expect(stdout.text).toBe("10\timage\tphoto-10.jpg\n");
});
});
+1
View File
@@ -1482,6 +1482,7 @@ describe("backup album folders", () => {
listCollections: () => albums.map((a) => a.collection), listCollections: () => albums.map((a) => a.collection),
listFiles: (id) => listFiles: (id) =>
albums.find((a) => a.collection.id === id)?.files ?? [], albums.find((a) => a.collection.id === id)?.files ?? [],
undecryptable: () => [],
original: async (fileID) => { original: async (fileID) => {
const path = join(root, `source-${fileID}`); const path = join(root, `source-${fileID}`);
writeFileSync(path, `original ${fileID}`); writeFileSync(path, `original ${fileID}`);
+122 -6
View File
@@ -10,7 +10,9 @@
* The return shape keeps live records and tombstones apart — `collections` / * The return shape keeps live records and tombstones apart — `collections` /
* `files` are decrypted live records, `deleted` is a plain list of the ids the * `files` are decrypted live records, `deleted` is a plain list of the ids the
* server tombstoned. A tombstone carries no decryptable key or metadata, so it * server tombstoned. A tombstone carries no decryptable key or metadata, so it
* is a bare id rather than a hollowed-out `Collection` / `EnteFile`. * is a bare id rather than a hollowed-out `Collection` / `EnteFile`. A live row
* that does not decrypt, or whose metadata is not JSON, is listed under
* `undecryptable` with the reason, so one bad row does not fail the call.
* *
* All tests inject a fake `fetch` and drive a real `Client` (built with * All tests inject a fake `fetch` and drive a real `Client` (built with
* `Client.fromJSON`) so the decryption path runs for real. Live rows are built * `Client.fromJSON`) so the decryption path runs for real. Live rows are built
@@ -91,27 +93,38 @@ const ownedCollectionRow = (
}; };
}; };
/** A live file row inside a collection, keyed under that collection's key. */ /**
* A live file row inside a collection, keyed under that collection's key. Its
* metadata decrypts to `metadata` when given, otherwise to the JSON a client
* uploads for `title`.
*/
const fileRow = ( const fileRow = (
collectionKey: Uint8Array, collectionKey: Uint8Array,
opts: { id: number; title: string; updationTime: number }, opts: {
id: number;
title: string;
updationTime: number;
metadata?: string;
},
): Record<string, unknown> => { ): Record<string, unknown> => {
const fileKey = sodium.crypto_secretbox_keygen(); const fileKey = sodium.crypto_secretbox_keygen();
const { ciphertext: encFileKey, nonce: fileKeyNonce } = secretboxEncrypt( const { ciphertext: encFileKey, nonce: fileKeyNonce } = secretboxEncrypt(
fileKey, fileKey,
collectionKey, collectionKey,
); );
const metadata = { const metadata =
opts.metadata ??
JSON.stringify({
title: opts.title, title: opts.title,
fileType: 0, fileType: 0,
creationTime: opts.updationTime, creationTime: opts.updationTime,
modificationTime: opts.updationTime, modificationTime: opts.updationTime,
}; });
const push = const push =
sodium.crypto_secretstream_xchacha20poly1305_init_push(fileKey); sodium.crypto_secretstream_xchacha20poly1305_init_push(fileKey);
const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push( const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push(
push.state, push.state,
new TextEncoder().encode(JSON.stringify(metadata)), new TextEncoder().encode(metadata),
null, null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
); );
@@ -131,6 +144,21 @@ const fileRow = (
}; };
}; };
/**
* `row` with its sealed key replaced by random bytes of the same length, as a
* corrupt row arrives: no key opens it, so the row cannot be decrypted.
*/
const withCorruptKey = (
row: Record<string, unknown>,
): Record<string, unknown> => ({
...row,
encryptedKey: toBase64(
sodium.randombytes_buf(
sodium.crypto_secretbox_KEYBYTES + sodium.crypto_secretbox_MACBYTES,
),
),
});
/** A tombstone row. Never decrypted, so only these fields are ever read. */ /** A tombstone row. Never decrypted, so only these fields are ever read. */
const tombstoneRow = ( const tombstoneRow = (
id: number, id: number,
@@ -278,6 +306,58 @@ describe("Client.filesSince", () => {
// It gave up after the first page rather than looping. // It gave up after the first page rather than looping.
expect(sinceTimes).toEqual(["50"]); expect(sinceTimes).toEqual(["50"]);
}); });
it("leaves out each file it cannot decrypt or parse, with the reason, and decrypts the rest", async () => {
const keys = buildKeys();
const collectionKey = sodium.crypto_secretbox_keygen();
// File 1002's key is sealed under no key quak holds, and file 1003's
// metadata decrypts to text that is not JSON. Neither may cost the
// caller file 1001: each is left out of `files` and reported under
// `undecryptable` with the reason instead of failing the call.
const { fetch } = recordingFetch(
jsonResponse({
diff: [
fileRow(collectionKey, {
id: 1001,
title: "fine.jpg",
updationTime: 100,
}),
withCorruptKey(
fileRow(collectionKey, {
id: 1002,
title: "bad-key.jpg",
updationTime: 200,
}),
),
fileRow(collectionKey, {
id: 1003,
title: "bad-metadata.jpg",
updationTime: 300,
metadata: "not json",
}),
],
hasMore: false,
}),
);
const client = Client.fromJSON(snapshotFor(keys), { fetch });
const { files, undecryptable, cursor } = await client.filesSince({
collectionID: 1,
collectionKey,
sinceTime: 0,
});
expect(files.map((f) => f.id)).toEqual([1001]);
expect(undecryptable).toEqual([
{ id: 1002, error: expect.stringContaining("wrong secret key") },
{ id: 1003, error: expect.stringContaining("JSON") },
]);
// The cursor moves past the two left out, as past any other row, so
// the server does not send them again until its copy changes. The
// caller has to keep what it was told here.
expect(cursor).toBe(300);
});
}); });
describe("Client.collectionsSince", () => { describe("Client.collectionsSince", () => {
@@ -331,6 +411,42 @@ describe("Client.collectionsSince", () => {
expect(result.cursor).toBe(150); expect(result.cursor).toBe(150);
expect(sinceTimes).toEqual(["150"]); expect(sinceTimes).toEqual(["150"]);
}); });
it("leaves out an album it cannot decrypt, with the reason, and decrypts the rest", async () => {
const keys = buildKeys();
// Album 2's key is sealed under no key quak holds. Without its key
// neither its name nor its files can be read, so it is left out and
// reported under `undecryptable`; album 1 is unaffected.
const { fetch } = recordingFetch(
jsonResponse({
collections: [
ownedCollectionRow(keys.masterKey, {
id: 1,
name: "Vacation",
updationTime: 100,
}),
withCorruptKey(
ownedCollectionRow(keys.masterKey, {
id: 2,
name: "Broken",
updationTime: 200,
}),
),
],
}),
);
const client = Client.fromJSON(snapshotFor(keys), { fetch });
const { collections, undecryptable, cursor } =
await client.collectionsSince({ sinceTime: 0 });
expect(collections.map((c) => c.name)).toEqual(["Vacation"]);
expect(undecryptable).toEqual([
{ id: 2, error: expect.stringContaining("wrong secret key") },
]);
expect(cursor).toBe(200);
});
}); });
describe("Client list wrappers still hide deletions", () => { describe("Client list wrappers still hide deletions", () => {
+23
View File
@@ -172,6 +172,29 @@ describe("MetadataStore", () => {
expect(store.listCollections()).toEqual([]); 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 () => { it("creates the directory 0700 and the file 0600", async () => {
const store = await MetadataStore.load(path); const store = await MetadataStore.load(path);
store.putCollection(sampleCollection()); store.putCollection(sampleCollection());