One album or file quak cannot decrypt no longer stops a refresh or a backup (closes #181)
check / check (push) Waiting to run

A file or album whose key, metadata or name does not decrypt used to
throw out of the refresh, so quak backup stopped before storing
anything, every run. Client.collectionsSince and filesSince now return
such a row in undecryptable with the reason, the refresh leaves it out,
and the store keeps the list in metadata.json until a later refresh
decrypts or deletes the record. lib.backup() records each such file in
failures.json unless a copy in another album decrypted, in which case it
stores the original from that copy. Each such album is listed in
BackupResult.undecryptableAlbums and printed as one FAILED album line.
quak backup exits 1 while either remains. An album or file that no
longer decrypts loses its album folder, JSON and links; its original
stays.

Model: opus-5-5
This commit was merged in pull request #188.
This commit is contained in:
2026-10-14 07:08:57 +02:00
parent 288825b11a
commit bb3b5cd2da
11 changed files with 956 additions and 77 deletions
+50 -5
View File
@@ -2,10 +2,11 @@
//
// The store keeps one `metadata.json` file holding the account's server
// state: the user id, a schema version, the cursor for the incremental
// collections listing, and the decrypted collection and file records. The
// whole file is read into RAM on load and rewritten as a whole on save; there
// is no partial update and no lock file. A separate refresh unit populates the
// store from the server — this module only stores what it is given.
// collections listing, the decrypted collection and file records, and the
// collections and files the refresh could not decrypt. The whole file is read
// into RAM on load and rewritten as a whole on save; there is no partial
// update and no lock file. A separate refresh unit populates the store from
// the server — this module only stores what it is given.
//
// The file is a cache, so it is never trusted to exist or to be intact: a
// missing or unreadable file loads as an empty store rather than an error, and
@@ -33,12 +34,22 @@ const FILE_MODE = 0o600;
type StoredCollection = Omit<Collection, "key"> & { key: string };
type StoredFile = Omit<EnteFile, "key"> & { key: string };
// A collection, or with `fileID` a file in it, that the last refresh to fetch
// it could not decrypt or parse, and why. It is kept until a later refresh
// fetches it again, because a refresh fetches only what changed.
export interface Undecryptable {
collectionID: number;
fileID?: number;
error: string;
}
interface StoredMetadata {
schemaVersion: number;
userID: number;
collectionsSinceTime: Microseconds;
collections: StoredCollection[];
files: StoredFile[];
undecryptable: Undecryptable[];
}
const encodeKey = (key: Uint8Array): string =>
@@ -67,6 +78,7 @@ export class MetadataStore {
private readonly collections = new Map<number, Collection>();
private readonly files = new Map<string, EnteFile>();
private undecryptable: Undecryptable[] = [];
private constructor(path: string) {
this.path = path;
@@ -107,6 +119,12 @@ export class MetadataStore {
};
store.files.set(fileKey(file.collectionID, file.id), file);
}
for (const record of parsed.undecryptable) {
if (typeof record.collectionID !== "number") {
throw new Error("damaged record of what did not decrypt");
}
store.undecryptable.push(record);
}
} catch {
// Any corruption discards the partial result: a half-read cache is
// worse than an empty one, since the refresh unit will rebuild it.
@@ -134,6 +152,7 @@ export class MetadataStore {
...f,
key: encodeKey(f.key),
})),
undecryptable: this.undecryptable,
};
const dir = dirname(this.path);
@@ -165,7 +184,8 @@ export class MetadataStore {
this.collections.set(collection.id, collection);
}
// Removing a collection also drops its file memberships: a file record is
// Removing a collection also drops its file memberships, and any record
// that it or one of its files could not be decrypted: a file record is
// only meaningful as part of a collection the cache still knows about.
deleteCollection(id: number): void {
this.collections.delete(id);
@@ -174,6 +194,9 @@ export class MetadataStore {
this.files.delete(key);
}
}
this.undecryptable = this.undecryptable.filter(
(record) => record.collectionID !== id,
);
}
getFile(collectionID: number, fileID: number): EnteFile | undefined {
@@ -205,4 +228,26 @@ export class MetadataStore {
deleteFile(collectionID: number, fileID: number): void {
this.files.delete(fileKey(collectionID, fileID));
}
listUndecryptable(): Undecryptable[] {
return [...this.undecryptable];
}
// Replaces any earlier record of the same collection or file.
putUndecryptable(record: Undecryptable): void {
this.deleteUndecryptable(record.collectionID, record.fileID);
this.undecryptable.push(record);
}
// Forgets the record of the collection, or with `fileID` of that file in
// it. Returns whether there was one.
deleteUndecryptable(collectionID: number, fileID?: number): boolean {
const before = this.undecryptable.length;
this.undecryptable = this.undecryptable.filter(
(record) =>
record.collectionID !== collectionID ||
record.fileID !== fileID,
);
return this.undecryptable.length < before;
}
}