Compare commits
3
Commits
893cc95ab1
...
76141ae919
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76141ae919 | ||
|
|
15ceac857a | ||
|
|
42a6c17d49 |
@@ -18,6 +18,15 @@ Update the README API reference section to match the current implementation.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-09-22: Added resumable, deletion-aware enumeration to `Client` (issue 38,
|
||||
closes issue 7). `collectionsSince`/`filesSince` take a starting cursor,
|
||||
decrypt live records, surface tombstoned ids in a separate `deleted` list (a
|
||||
tombstone has nothing to decrypt, so it is a bare id, not a hollow record),
|
||||
and return the max `updationTime` seen as the cursor to resume from.
|
||||
`filesSince` refuses to loop when the diff reports `hasMore` without advancing
|
||||
the cursor (issue 7). `listCollections`/`listFiles` are now thin wrappers that
|
||||
enumerate from `sinceTime: 0` and drop deletions, so existing callers are
|
||||
unaffected.
|
||||
- 2026-09-22: Carried file size, thumbnail size, and the deletion flag through
|
||||
`decryptFile` (issue 37, foundation for the cache/API design). Live files now
|
||||
populate `file.size`/`thumbnail.size` from the server's `info` (left
|
||||
|
||||
+107
-32
@@ -37,6 +37,22 @@ export interface ClientSnapshot {
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
// The result of a resumable enumeration. Live decrypted records and deleted
|
||||
// ids are kept apart on purpose: a tombstone carries no key or metadata to
|
||||
// decrypt, so it is a bare id rather than a hollowed-out record. `cursor` is
|
||||
// the max `updationTime` seen, to pass back into the next call.
|
||||
export interface CollectionsPage {
|
||||
collections: Collection[];
|
||||
deleted: number[];
|
||||
cursor: number;
|
||||
}
|
||||
|
||||
export interface FilesPage {
|
||||
files: EnteFile[];
|
||||
deleted: number[];
|
||||
cursor: number;
|
||||
}
|
||||
|
||||
export class Client {
|
||||
private readonly api: ApiClient;
|
||||
private readonly email: string;
|
||||
@@ -150,52 +166,111 @@ export class Client {
|
||||
this.api.clearAuthToken();
|
||||
}
|
||||
|
||||
async listCollections(): Promise<Collection[]> {
|
||||
// Enumerate collections changed since `sinceTime`. Live collections are
|
||||
// decrypted; tombstoned ones (isDeleted) are surfaced as bare ids. The
|
||||
// returned cursor is the max `updationTime` seen — including tombstones, so
|
||||
// the next sync resumes past them — falling back to `sinceTime` when the
|
||||
// response is empty. `/collections/v2` returns the whole changed set in one
|
||||
// response, so there is no pagination here.
|
||||
async collectionsSince(args: {
|
||||
sinceTime: number;
|
||||
}): Promise<CollectionsPage> {
|
||||
this.assertLoggedIn();
|
||||
const { collections } = await this.api.getJSON<{
|
||||
const { collections: raws } = await this.api.getJSON<{
|
||||
collections: RawCollection[];
|
||||
}>("/collections/v2", { sinceTime: 0 });
|
||||
// The sync API keeps returning deleted collections as tombstones
|
||||
// (isDeleted: true); their diff endpoint 404s, so drop them.
|
||||
return collections
|
||||
.filter((raw) => !raw.isDeleted)
|
||||
.map((raw) =>
|
||||
decryptCollection(
|
||||
raw,
|
||||
{
|
||||
masterKey: this.masterKey,
|
||||
publicKey: this.publicKey,
|
||||
secretKey: this.secretKey,
|
||||
},
|
||||
this.userID,
|
||||
),
|
||||
);
|
||||
}>("/collections/v2", { sinceTime: args.sinceTime });
|
||||
|
||||
const collections: Collection[] = [];
|
||||
const deleted: number[] = [];
|
||||
let cursor = args.sinceTime;
|
||||
for (const raw of raws) {
|
||||
if (raw.isDeleted) {
|
||||
deleted.push(raw.id);
|
||||
} else {
|
||||
collections.push(
|
||||
decryptCollection(
|
||||
raw,
|
||||
{
|
||||
masterKey: this.masterKey,
|
||||
publicKey: this.publicKey,
|
||||
secretKey: this.secretKey,
|
||||
},
|
||||
this.userID,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (raw.updationTime > cursor) cursor = raw.updationTime;
|
||||
}
|
||||
return { collections, deleted, cursor };
|
||||
}
|
||||
|
||||
async listFiles(
|
||||
collectionID: number,
|
||||
collectionKey: Uint8Array,
|
||||
): Promise<EnteFile[]> {
|
||||
// Enumerate a collection's files changed since `sinceTime`, paginating the
|
||||
// diff from that cursor. Live rows are decrypted; tombstoned ones are
|
||||
// surfaced as bare ids. Returns the final cursor to resume from.
|
||||
async filesSince(args: {
|
||||
collectionID: number;
|
||||
collectionKey: Uint8Array;
|
||||
sinceTime: number;
|
||||
}): Promise<FilesPage> {
|
||||
this.assertLoggedIn();
|
||||
const allFiles: EnteFile[] = [];
|
||||
let sinceTime = 0;
|
||||
const { collectionID, collectionKey } = args;
|
||||
const files: EnteFile[] = [];
|
||||
const deleted: number[] = [];
|
||||
let cursor = args.sinceTime;
|
||||
for (;;) {
|
||||
const { diff, hasMore } = await this.api.getJSON<{
|
||||
diff: RawEnteFile[];
|
||||
hasMore: boolean;
|
||||
}>("/collections/v2/diff", { collectionID, sinceTime });
|
||||
}>("/collections/v2/diff", { collectionID, sinceTime: cursor });
|
||||
|
||||
let pageMax = cursor;
|
||||
for (const raw of diff) {
|
||||
if (!raw.isDeleted) {
|
||||
allFiles.push(decryptFile(raw, collectionKey));
|
||||
}
|
||||
if (raw.updationTime > sinceTime) {
|
||||
sinceTime = raw.updationTime;
|
||||
if (raw.isDeleted) {
|
||||
deleted.push(raw.id);
|
||||
} else {
|
||||
files.push(decryptFile(raw, collectionKey));
|
||||
}
|
||||
if (raw.updationTime > pageMax) pageMax = raw.updationTime;
|
||||
}
|
||||
if (!hasMore) break;
|
||||
|
||||
if (!hasMore) {
|
||||
cursor = pageMax;
|
||||
break;
|
||||
}
|
||||
// The server says there is more, but this page did not advance the
|
||||
// cursor: following hasMore would refetch the same page forever
|
||||
// (#7). Stop with a clear error instead of looping.
|
||||
if (pageMax <= cursor) {
|
||||
throw new Error(
|
||||
`/collections/v2/diff for collection ${collectionID} ` +
|
||||
`returned hasMore with a cursor that did not advance ` +
|
||||
`(stuck at ${cursor}); refusing to loop`,
|
||||
);
|
||||
}
|
||||
cursor = pageMax;
|
||||
}
|
||||
return allFiles;
|
||||
return { files, deleted, cursor };
|
||||
}
|
||||
|
||||
// Whole-account listing: every live collection, deletions hidden. A thin
|
||||
// wrapper over `collectionsSince` from the beginning of time.
|
||||
async listCollections(): Promise<Collection[]> {
|
||||
const { collections } = await this.collectionsSince({ sinceTime: 0 });
|
||||
return collections;
|
||||
}
|
||||
|
||||
// Every live file in a collection, deletions hidden. A thin wrapper over
|
||||
// `filesSince` from the beginning of time.
|
||||
async listFiles(
|
||||
collectionID: number,
|
||||
collectionKey: Uint8Array,
|
||||
): Promise<EnteFile[]> {
|
||||
const { files } = await this.filesSince({
|
||||
collectionID,
|
||||
collectionKey,
|
||||
sinceTime: 0,
|
||||
});
|
||||
return files;
|
||||
}
|
||||
|
||||
async downloadFile(
|
||||
|
||||
+7
-1
@@ -1,6 +1,12 @@
|
||||
export const VERSION = "0.0.0";
|
||||
|
||||
export { Client, type LoginOptions, type ClientSnapshot } from "./client.js";
|
||||
export {
|
||||
Client,
|
||||
type LoginOptions,
|
||||
type ClientSnapshot,
|
||||
type CollectionsPage,
|
||||
type FilesPage,
|
||||
} from "./client.js";
|
||||
export {
|
||||
ApiClient,
|
||||
ApiError,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// On-disk JSON metadata store for the local cache.
|
||||
//
|
||||
// The store keeps one `metadata.json` file holding the account's server
|
||||
// state: the user id, a schema version, the cursor for the incremental
|
||||
// collections listing, and the decrypted collection and file records. The
|
||||
// whole file is read into RAM on load and rewritten as a whole on save; there
|
||||
// is no partial update and no lock file. A separate refresh unit populates the
|
||||
// store from the server — this module only stores what it is given.
|
||||
//
|
||||
// The file is a cache, so it is never trusted to exist or to be intact: a
|
||||
// missing or unreadable file loads as an empty store rather than an error, and
|
||||
// the refresh unit then repopulates it.
|
||||
|
||||
import { mkdir, chmod, readFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import { writeAtomic } from "../download/index.js";
|
||||
import type { Collection, EnteFile, Microseconds } from "../model/types.js";
|
||||
|
||||
// Bumped only when the on-disk shape changes incompatibly. A file written
|
||||
// under a different version is discarded on load (see `load`): re-fetching
|
||||
// from the server is always safe and cheaper than migrating a cache.
|
||||
export const METADATA_SCHEMA_VERSION = 1;
|
||||
|
||||
// Directory and file modes match `session.json`: the records hold decrypted
|
||||
// key material, so on a shared machine only the owner may read them.
|
||||
const DIR_MODE = 0o700;
|
||||
const FILE_MODE = 0o600;
|
||||
|
||||
// On-disk shapes. They mirror the in-memory model exactly except for the
|
||||
// binary `key`, which JSON cannot hold and which is stored as base64.
|
||||
type StoredCollection = Omit<Collection, "key"> & { key: string };
|
||||
type StoredFile = Omit<EnteFile, "key"> & { key: string };
|
||||
|
||||
interface StoredMetadata {
|
||||
schemaVersion: number;
|
||||
userID: number;
|
||||
collectionsSinceTime: Microseconds;
|
||||
collections: StoredCollection[];
|
||||
files: StoredFile[];
|
||||
}
|
||||
|
||||
const encodeKey = (key: Uint8Array): string =>
|
||||
Buffer.from(key).toString("base64");
|
||||
|
||||
const decodeKey = (encoded: string): Uint8Array =>
|
||||
new Uint8Array(Buffer.from(encoded, "base64"));
|
||||
|
||||
// A file membership is identified by the pair (collectionID, fileID): the same
|
||||
// underlying file can belong to several collections, each a distinct record
|
||||
// with its own key.
|
||||
const fileKey = (collectionID: number, fileID: number): string =>
|
||||
`${collectionID}:${fileID}`;
|
||||
|
||||
export class MetadataStore {
|
||||
readonly path: string;
|
||||
readonly schemaVersion = METADATA_SCHEMA_VERSION;
|
||||
userID = 0;
|
||||
collectionsSinceTime: Microseconds = 0;
|
||||
|
||||
private readonly collections = new Map<number, Collection>();
|
||||
private readonly files = new Map<string, EnteFile>();
|
||||
|
||||
private constructor(path: string) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
// Load the store at `path`. A missing file, an unreadable one, unparseable
|
||||
// contents, or a mismatched schema version all yield an empty store bound
|
||||
// to that path — never a thrown error, because the file is only a cache.
|
||||
static async load(path: string): Promise<MetadataStore> {
|
||||
const store = new MetadataStore(path);
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(path, "utf8");
|
||||
} catch {
|
||||
return store;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as StoredMetadata;
|
||||
if (parsed.schemaVersion !== METADATA_SCHEMA_VERSION) {
|
||||
return store;
|
||||
}
|
||||
store.userID = parsed.userID ?? 0;
|
||||
store.collectionsSinceTime = parsed.collectionsSinceTime ?? 0;
|
||||
for (const stored of parsed.collections ?? []) {
|
||||
const collection: Collection = {
|
||||
...stored,
|
||||
key: decodeKey(stored.key),
|
||||
};
|
||||
store.collections.set(collection.id, collection);
|
||||
}
|
||||
for (const stored of parsed.files ?? []) {
|
||||
const file: EnteFile = {
|
||||
...stored,
|
||||
key: decodeKey(stored.key),
|
||||
};
|
||||
store.files.set(fileKey(file.collectionID, file.id), file);
|
||||
}
|
||||
} catch {
|
||||
// Any corruption discards the partial result: a half-read cache is
|
||||
// worse than an empty one, since the refresh unit will rebuild it.
|
||||
return new MetadataStore(path);
|
||||
}
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
// Rewrite the whole file. The directory is created 0700 and the file left
|
||||
// 0600; the write itself is the download layer's durable atomic writer
|
||||
// (temp file, fsync, rename, dir fsync), so a reader never sees a partial
|
||||
// file and a crash cannot leave a truncated one. There is no lock file and
|
||||
// no `sync()` beyond the writer's own fsyncs.
|
||||
async save(): Promise<void> {
|
||||
const model: StoredMetadata = {
|
||||
schemaVersion: METADATA_SCHEMA_VERSION,
|
||||
userID: this.userID,
|
||||
collectionsSinceTime: this.collectionsSinceTime,
|
||||
collections: [...this.collections.values()].map((c) => ({
|
||||
...c,
|
||||
key: encodeKey(c.key),
|
||||
})),
|
||||
files: [...this.files.values()].map((f) => ({
|
||||
...f,
|
||||
key: encodeKey(f.key),
|
||||
})),
|
||||
};
|
||||
|
||||
const dir = dirname(this.path);
|
||||
// chmod after mkdir so the mode is 0700 even when the directory
|
||||
// already existed with a looser mode; mkdir alone would not tighten
|
||||
// an existing directory.
|
||||
await mkdir(dir, { recursive: true, mode: DIR_MODE });
|
||||
await chmod(dir, DIR_MODE);
|
||||
|
||||
const payload = new TextEncoder().encode(
|
||||
JSON.stringify(model, null, 2),
|
||||
);
|
||||
await writeAtomic(this.path, payload);
|
||||
// The atomic writer's temp file inherits the default mode; tighten the
|
||||
// renamed file to 0600. The 0700 directory already keeps other users
|
||||
// out during the brief window before this runs.
|
||||
await chmod(this.path, FILE_MODE);
|
||||
}
|
||||
|
||||
getCollection(id: number): Collection | undefined {
|
||||
return this.collections.get(id);
|
||||
}
|
||||
|
||||
listCollections(): Collection[] {
|
||||
return [...this.collections.values()];
|
||||
}
|
||||
|
||||
putCollection(collection: Collection): void {
|
||||
this.collections.set(collection.id, collection);
|
||||
}
|
||||
|
||||
// Removing a collection also drops its file memberships: a file record is
|
||||
// only meaningful as part of a collection the cache still knows about.
|
||||
deleteCollection(id: number): void {
|
||||
this.collections.delete(id);
|
||||
for (const [key, file] of this.files) {
|
||||
if (file.collectionID === id) {
|
||||
this.files.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getFile(collectionID: number, fileID: number): EnteFile | undefined {
|
||||
return this.files.get(fileKey(collectionID, fileID));
|
||||
}
|
||||
|
||||
listFiles(collectionID: number): EnteFile[] {
|
||||
return [...this.files.values()].filter(
|
||||
(f) => f.collectionID === collectionID,
|
||||
);
|
||||
}
|
||||
|
||||
putFile(file: EnteFile): void {
|
||||
this.files.set(fileKey(file.collectionID, file.id), file);
|
||||
}
|
||||
|
||||
deleteFile(collectionID: number, fileID: number): void {
|
||||
this.files.delete(fileKey(collectionID, fileID));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Tests for the resumable, deletion-aware enumeration variants on `Client`:
|
||||
* `collectionsSince` and `filesSince`.
|
||||
*
|
||||
* The whole-account methods `listCollections` / `listFiles` always start at
|
||||
* `sinceTime: 0` and hide deletions. The cache refresh needs the opposite:
|
||||
* start from a saved cursor, learn what was deleted, and get back a cursor to
|
||||
* resume from next time. These two methods provide that.
|
||||
*
|
||||
* The return shape keeps live records and tombstones apart — `collections` /
|
||||
* `files` are decrypted live records, `deleted` is a plain list of the ids the
|
||||
* server tombstoned. A tombstone carries no decryptable key or metadata, so it
|
||||
* is a bare id rather than a hollowed-out `Collection` / `EnteFile`.
|
||||
*
|
||||
* All tests inject a fake `fetch` and drive a real `Client` (built with
|
||||
* `Client.fromJSON`) so the decryption path runs for real. Live rows are built
|
||||
* with libsodium exactly as the server would encrypt them; tombstone rows carry
|
||||
* only the fields the code reads (`id`, `updationTime`, `isDeleted`), because
|
||||
* they are never decrypted.
|
||||
*/
|
||||
|
||||
import sodium from "libsodium-wrappers-sumo";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { init, toBase64 } from "../../src/crypto/index.js";
|
||||
import { Client, type ClientSnapshot } from "../../src/client.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const USER_ID = 42;
|
||||
|
||||
interface Keys {
|
||||
masterKey: Uint8Array;
|
||||
publicKey: Uint8Array;
|
||||
secretKey: Uint8Array;
|
||||
}
|
||||
|
||||
const buildKeys = (): Keys => {
|
||||
const kp = sodium.crypto_box_keypair();
|
||||
return {
|
||||
masterKey: sodium.crypto_secretbox_keygen(),
|
||||
publicKey: kp.publicKey,
|
||||
secretKey: kp.privateKey,
|
||||
};
|
||||
};
|
||||
|
||||
const snapshotFor = (keys: Keys): ClientSnapshot => ({
|
||||
email: "user@example.com",
|
||||
userID: USER_ID,
|
||||
token: "test-token",
|
||||
masterKey: toBase64(keys.masterKey),
|
||||
secretKey: toBase64(keys.secretKey),
|
||||
publicKey: toBase64(keys.publicKey),
|
||||
});
|
||||
|
||||
const secretboxEncrypt = (
|
||||
plaintext: Uint8Array,
|
||||
key: Uint8Array,
|
||||
): { ciphertext: Uint8Array; nonce: Uint8Array } => {
|
||||
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||
return {
|
||||
ciphertext: sodium.crypto_secretbox_easy(plaintext, nonce, key),
|
||||
nonce,
|
||||
};
|
||||
};
|
||||
|
||||
/** An owned collection row as the server sends it, keyed under the master key. */
|
||||
const ownedCollectionRow = (
|
||||
masterKey: Uint8Array,
|
||||
opts: { id: number; name: string; updationTime: number },
|
||||
): Record<string, unknown> => {
|
||||
const collectionKey = sodium.crypto_secretbox_keygen();
|
||||
const { ciphertext: encKey, nonce: keyNonce } = secretboxEncrypt(
|
||||
collectionKey,
|
||||
masterKey,
|
||||
);
|
||||
const { ciphertext: encName, nonce: nameNonce } = secretboxEncrypt(
|
||||
new TextEncoder().encode(opts.name),
|
||||
collectionKey,
|
||||
);
|
||||
return {
|
||||
id: opts.id,
|
||||
owner: { id: USER_ID },
|
||||
encryptedKey: toBase64(encKey),
|
||||
keyDecryptionNonce: toBase64(keyNonce),
|
||||
encryptedName: toBase64(encName),
|
||||
nameDecryptionNonce: toBase64(nameNonce),
|
||||
type: "album",
|
||||
updationTime: opts.updationTime,
|
||||
};
|
||||
};
|
||||
|
||||
/** A live file row inside a collection, keyed under that collection's key. */
|
||||
const fileRow = (
|
||||
collectionKey: Uint8Array,
|
||||
opts: { id: number; title: string; updationTime: number },
|
||||
): Record<string, unknown> => {
|
||||
const fileKey = sodium.crypto_secretbox_keygen();
|
||||
const { ciphertext: encFileKey, nonce: fileKeyNonce } = secretboxEncrypt(
|
||||
fileKey,
|
||||
collectionKey,
|
||||
);
|
||||
const metadata = {
|
||||
title: opts.title,
|
||||
fileType: 0,
|
||||
creationTime: opts.updationTime,
|
||||
modificationTime: opts.updationTime,
|
||||
};
|
||||
const push =
|
||||
sodium.crypto_secretstream_xchacha20poly1305_init_push(fileKey);
|
||||
const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push(
|
||||
push.state,
|
||||
new TextEncoder().encode(JSON.stringify(metadata)),
|
||||
null,
|
||||
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
|
||||
);
|
||||
return {
|
||||
id: opts.id,
|
||||
collectionID: 1,
|
||||
ownerID: USER_ID,
|
||||
encryptedKey: toBase64(encFileKey),
|
||||
keyDecryptionNonce: toBase64(fileKeyNonce),
|
||||
metadata: {
|
||||
encryptedData: toBase64(encMeta),
|
||||
decryptionHeader: toBase64(push.header),
|
||||
},
|
||||
file: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
|
||||
thumbnail: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) },
|
||||
updationTime: opts.updationTime,
|
||||
};
|
||||
};
|
||||
|
||||
/** A tombstone row. Never decrypted, so only these fields are ever read. */
|
||||
const tombstoneRow = (
|
||||
id: number,
|
||||
updationTime: number,
|
||||
): Record<string, unknown> => ({
|
||||
id,
|
||||
updationTime,
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
const jsonResponse = (body: unknown): Response =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
/**
|
||||
* A fetch that serves canned responses in order and records the `sinceTime`
|
||||
* query parameter each request carried, so tests can prove the cursor is
|
||||
* threaded from one page (and one call) to the next.
|
||||
*/
|
||||
const recordingFetch = (
|
||||
...responses: Response[]
|
||||
): { fetch: typeof globalThis.fetch; sinceTimes: (string | null)[] } => {
|
||||
const sinceTimes: (string | null)[] = [];
|
||||
let i = 0;
|
||||
const fake = async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
sinceTimes.push(new URL(url).searchParams.get("sinceTime"));
|
||||
if (i >= responses.length) {
|
||||
throw new Error(`recordingFetch: no response for call #${i}`);
|
||||
}
|
||||
return responses[i++]!;
|
||||
};
|
||||
return { fetch: fake as typeof globalThis.fetch, sinceTimes };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Client.filesSince", () => {
|
||||
beforeAll(async () => {
|
||||
await init();
|
||||
await sodium.ready;
|
||||
});
|
||||
|
||||
it("pages from the given cursor, decrypts live rows, and collects tombstones", async () => {
|
||||
const keys = buildKeys();
|
||||
const collectionKey = sodium.crypto_secretbox_keygen();
|
||||
|
||||
// Page 1 mixes a live file and a tombstone; the tombstone has the
|
||||
// higher updationTime, so it — not the live row — sets the cursor the
|
||||
// second page must be fetched from.
|
||||
const { fetch, sinceTimes } = recordingFetch(
|
||||
jsonResponse({
|
||||
diff: [
|
||||
fileRow(collectionKey, {
|
||||
id: 1001,
|
||||
title: "first.jpg",
|
||||
updationTime: 100,
|
||||
}),
|
||||
tombstoneRow(1002, 150),
|
||||
],
|
||||
hasMore: true,
|
||||
}),
|
||||
jsonResponse({
|
||||
diff: [
|
||||
fileRow(collectionKey, {
|
||||
id: 1003,
|
||||
title: "second.jpg",
|
||||
updationTime: 200,
|
||||
}),
|
||||
],
|
||||
hasMore: false,
|
||||
}),
|
||||
);
|
||||
const client = Client.fromJSON(snapshotFor(keys), { fetch });
|
||||
|
||||
const { files, deleted, cursor } = await client.filesSince({
|
||||
collectionID: 1,
|
||||
collectionKey,
|
||||
sinceTime: 0,
|
||||
});
|
||||
|
||||
expect(files.map((f) => f.id)).toEqual([1001, 1003]);
|
||||
expect(files.map((f) => f.metadata.title)).toEqual([
|
||||
"first.jpg",
|
||||
"second.jpg",
|
||||
]);
|
||||
expect(deleted).toEqual([1002]);
|
||||
expect(cursor).toBe(200);
|
||||
// First request started at the caller's cursor; the second resumed
|
||||
// from the max updationTime seen on the first page (the tombstone's).
|
||||
expect(sinceTimes).toEqual(["0", "150"]);
|
||||
});
|
||||
|
||||
it("fetches only newer rows when the returned cursor is passed back in", async () => {
|
||||
const keys = buildKeys();
|
||||
const collectionKey = sodium.crypto_secretbox_keygen();
|
||||
|
||||
const { fetch, sinceTimes } = recordingFetch(
|
||||
jsonResponse({ diff: [], hasMore: false }),
|
||||
);
|
||||
const client = Client.fromJSON(snapshotFor(keys), { fetch });
|
||||
|
||||
const result = await client.filesSince({
|
||||
collectionID: 1,
|
||||
collectionKey,
|
||||
sinceTime: 200,
|
||||
});
|
||||
|
||||
expect(result.files).toEqual([]);
|
||||
expect(result.deleted).toEqual([]);
|
||||
// An empty diff advances nothing: the cursor falls back to the input.
|
||||
expect(result.cursor).toBe(200);
|
||||
expect(sinceTimes).toEqual(["200"]);
|
||||
});
|
||||
|
||||
it("stops and throws when the server claims more but does not advance (#7)", async () => {
|
||||
const keys = buildKeys();
|
||||
const collectionKey = sodium.crypto_secretbox_keygen();
|
||||
|
||||
// hasMore is true, but the page's max updationTime (50) does not exceed
|
||||
// the cursor the request was made with (50). Following hasMore here
|
||||
// would refetch this same page forever.
|
||||
const { fetch, sinceTimes } = recordingFetch(
|
||||
jsonResponse({ diff: [tombstoneRow(1, 50)], hasMore: true }),
|
||||
jsonResponse({ diff: [tombstoneRow(1, 50)], hasMore: true }),
|
||||
);
|
||||
const client = Client.fromJSON(snapshotFor(keys), { fetch });
|
||||
|
||||
await expect(
|
||||
client.filesSince({
|
||||
collectionID: 1,
|
||||
collectionKey,
|
||||
sinceTime: 50,
|
||||
}),
|
||||
).rejects.toThrow(/not advance|non-advancing/i);
|
||||
// It gave up after the first page rather than looping.
|
||||
expect(sinceTimes).toEqual(["50"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Client.collectionsSince", () => {
|
||||
beforeAll(async () => {
|
||||
await init();
|
||||
await sodium.ready;
|
||||
});
|
||||
|
||||
it("decrypts live collections, collects tombstones, and returns a cursor", async () => {
|
||||
const keys = buildKeys();
|
||||
|
||||
const { fetch, sinceTimes } = recordingFetch(
|
||||
jsonResponse({
|
||||
collections: [
|
||||
ownedCollectionRow(keys.masterKey, {
|
||||
id: 1,
|
||||
name: "Vacation",
|
||||
updationTime: 100,
|
||||
}),
|
||||
tombstoneRow(3, 150),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const client = Client.fromJSON(snapshotFor(keys), { fetch });
|
||||
|
||||
const { collections, deleted, cursor } = await client.collectionsSince({
|
||||
sinceTime: 0,
|
||||
});
|
||||
|
||||
expect(collections.map((c) => c.id)).toEqual([1]);
|
||||
expect(collections[0]!.name).toBe("Vacation");
|
||||
expect(deleted).toEqual([3]);
|
||||
// The tombstone's updationTime advances the cursor too, so the next
|
||||
// sync starts after it rather than seeing it again.
|
||||
expect(cursor).toBe(150);
|
||||
expect(sinceTimes).toEqual(["0"]);
|
||||
});
|
||||
|
||||
it("falls back to the input cursor on an empty response", async () => {
|
||||
const keys = buildKeys();
|
||||
|
||||
const { fetch, sinceTimes } = recordingFetch(
|
||||
jsonResponse({ collections: [] }),
|
||||
);
|
||||
const client = Client.fromJSON(snapshotFor(keys), { fetch });
|
||||
|
||||
const result = await client.collectionsSince({ sinceTime: 150 });
|
||||
|
||||
expect(result.collections).toEqual([]);
|
||||
expect(result.deleted).toEqual([]);
|
||||
expect(result.cursor).toBe(150);
|
||||
expect(sinceTimes).toEqual(["150"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Client list wrappers still hide deletions", () => {
|
||||
beforeAll(async () => {
|
||||
await init();
|
||||
await sodium.ready;
|
||||
});
|
||||
|
||||
it("listFiles drops tombstones and returns only live files", async () => {
|
||||
const keys = buildKeys();
|
||||
const collectionKey = sodium.crypto_secretbox_keygen();
|
||||
|
||||
const { fetch, sinceTimes } = recordingFetch(
|
||||
jsonResponse({
|
||||
diff: [
|
||||
fileRow(collectionKey, {
|
||||
id: 7,
|
||||
title: "keep.jpg",
|
||||
updationTime: 100,
|
||||
}),
|
||||
tombstoneRow(8, 150),
|
||||
],
|
||||
hasMore: false,
|
||||
}),
|
||||
);
|
||||
const client = Client.fromJSON(snapshotFor(keys), { fetch });
|
||||
|
||||
const files = await client.listFiles(1, collectionKey);
|
||||
|
||||
expect(files.map((f) => f.id)).toEqual([7]);
|
||||
// The wrapper starts a full enumeration from zero.
|
||||
expect(sinceTimes).toEqual(["0"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Tests for the on-disk JSON metadata store (`MetadataStore`).
|
||||
*
|
||||
* The store is the local cache the library keeps of the account's server
|
||||
* state: one `metadata.json` file holding the user id, a schema version, the
|
||||
* cursor for the incremental collections listing, and the decrypted
|
||||
* collection and file records. The whole file is read into RAM on load and
|
||||
* rewritten as a whole on save. A separate refresh unit (issue #42) is what
|
||||
* populates it; this unit only stores.
|
||||
*
|
||||
* Four contracts are load-bearing and each is exercised below:
|
||||
*
|
||||
* 1. **Round-trip fidelity.** Everything put into the store — including the
|
||||
* binary decryption keys, which JSON cannot hold directly and which the
|
||||
* store base64-encodes — comes back byte-for-byte after a save and a fresh
|
||||
* load. A cache that quietly dropped or mangled a field would hand the
|
||||
* caller wrong keys or stale metadata.
|
||||
*
|
||||
* 2. **A missing or corrupt file loads as an empty store, never an error.**
|
||||
* The file is only a cache: if it is absent (first run) or unreadable
|
||||
* (interrupted write on an older build, disk corruption, hand-editing),
|
||||
* the right answer is to start empty and let the refresh unit repopulate,
|
||||
* not to crash the whole library.
|
||||
*
|
||||
* 3. **Writes are atomic and durable.** The store reuses the same
|
||||
* fsync-before-rename atomic writer the download layer uses, so a reader
|
||||
* never sees a half-written file and a crash cannot leave a truncated one.
|
||||
* The observable consequence tested here is that a save leaves exactly the
|
||||
* destination file behind — no temporary sibling — and that overwriting an
|
||||
* existing store preserves a complete, re-loadable file.
|
||||
*
|
||||
* 4. **Permissions match `session.json`.** The directory is `0700` and the
|
||||
* file is `0600`, because the records contain decrypted key material and
|
||||
* must not be readable by other users on a shared machine.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
MetadataStore,
|
||||
METADATA_SCHEMA_VERSION,
|
||||
} from "../../src/library/store.js";
|
||||
import type { Collection, EnteFile } from "../../src/model/types.js";
|
||||
|
||||
// A representative decrypted collection, including a binary key and all three
|
||||
// magic-metadata layers, so the round-trip test proves every field survives.
|
||||
const sampleCollection = (): Collection => ({
|
||||
id: 12345,
|
||||
ownerID: 42,
|
||||
key: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]),
|
||||
name: "Holiday 2026",
|
||||
type: "album",
|
||||
updationTime: 1_700_000_000_000_000,
|
||||
isShared: true,
|
||||
magicMetadata: { visibility: 0 },
|
||||
pubMagicMetadata: { subType: 0, coverID: 999 },
|
||||
sharedMagicMetadata: { note: "shared with a friend" },
|
||||
});
|
||||
|
||||
// A representative decrypted file membership: metadata, both blob headers, a
|
||||
// binary key, a content hash, and file/thumbnail sizes.
|
||||
const sampleFile = (): EnteFile => ({
|
||||
id: 67890,
|
||||
collectionID: 12345,
|
||||
ownerID: 42,
|
||||
key: new Uint8Array([9, 8, 7, 6, 5, 4, 3, 2, 1]),
|
||||
metadata: {
|
||||
title: "IMG_0001.jpg",
|
||||
fileType: "image",
|
||||
creationTime: 1_699_000_000_000_000,
|
||||
modificationTime: 1_699_000_500_000_000,
|
||||
latitude: 52.52,
|
||||
longitude: 13.405,
|
||||
hash: "sha256:deadbeef",
|
||||
},
|
||||
magicMetadata: { editedName: "sunset" },
|
||||
pubMagicMetadata: { editedTime: 1_699_000_600_000_000 },
|
||||
file: { decryptionHeader: "ZmlsZUhlYWRlcg==", size: 4_194_304 },
|
||||
thumbnail: { decryptionHeader: "dGh1bWJIZWFkZXI=", size: 8192 },
|
||||
updationTime: 1_700_000_100_000_000,
|
||||
});
|
||||
|
||||
describe("MetadataStore", () => {
|
||||
let dir: string;
|
||||
let path: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "quak-store-"));
|
||||
// Deliberately nest the store one level below the temp dir so save()
|
||||
// has to create its own directory and set its mode.
|
||||
path = join(dir, "cache", "metadata.json");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("round-trips the whole model, keys and all", async () => {
|
||||
const store = await MetadataStore.load(path);
|
||||
store.userID = 42;
|
||||
store.collectionsSinceTime = 1_700_000_000_000_000;
|
||||
store.putCollection(sampleCollection());
|
||||
store.putFile(sampleFile());
|
||||
await store.save();
|
||||
|
||||
const reloaded = await MetadataStore.load(path);
|
||||
expect(reloaded.userID).toBe(42);
|
||||
expect(reloaded.collectionsSinceTime).toBe(1_700_000_000_000_000);
|
||||
|
||||
// The binary key must come back as the exact bytes, not a base64
|
||||
// string or a plain object of numbered keys.
|
||||
const collection = reloaded.getCollection(12345);
|
||||
expect(collection).toEqual(sampleCollection());
|
||||
expect(collection?.key).toBeInstanceOf(Uint8Array);
|
||||
|
||||
const file = reloaded.getFile(12345, 67890);
|
||||
expect(file).toEqual(sampleFile());
|
||||
expect(file?.key).toBeInstanceOf(Uint8Array);
|
||||
|
||||
expect(reloaded.listCollections()).toHaveLength(1);
|
||||
expect(reloaded.listFiles(12345)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("writes the declared schema version", async () => {
|
||||
const store = await MetadataStore.load(path);
|
||||
await store.save();
|
||||
const reloaded = await MetadataStore.load(path);
|
||||
expect(reloaded.schemaVersion).toBe(METADATA_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
it("loads an empty store when the file is missing", async () => {
|
||||
const store = await MetadataStore.load(path);
|
||||
expect(store.userID).toBe(0);
|
||||
expect(store.listCollections()).toEqual([]);
|
||||
expect(store.getCollection(1)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("loads an empty store when the file is corrupt", async () => {
|
||||
mkdirSync(join(dir, "cache"), { recursive: true });
|
||||
writeFileSync(path, "{ this is not valid json ][");
|
||||
const store = await MetadataStore.load(path);
|
||||
expect(store.listCollections()).toEqual([]);
|
||||
expect(store.listFiles(12345)).toEqual([]);
|
||||
});
|
||||
|
||||
it("loads an empty store when the schema version does not match", async () => {
|
||||
// A cache written by a future build with an incompatible schema is
|
||||
// discarded rather than misread; the refresh unit repopulates it.
|
||||
mkdirSync(join(dir, "cache"), { recursive: true });
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify({
|
||||
schemaVersion: METADATA_SCHEMA_VERSION + 1,
|
||||
userID: 42,
|
||||
collectionsSinceTime: 0,
|
||||
collections: [],
|
||||
files: [],
|
||||
}),
|
||||
);
|
||||
const store = await MetadataStore.load(path);
|
||||
expect(store.userID).toBe(0);
|
||||
expect(store.listCollections()).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates the directory 0700 and the file 0600", async () => {
|
||||
const store = await MetadataStore.load(path);
|
||||
store.putCollection(sampleCollection());
|
||||
await store.save();
|
||||
|
||||
// Directory 0700, file 0600: on a shared machine the decrypted keys
|
||||
// in this file must be readable only by their owner. Mask to the
|
||||
// permission bits; the file-type bits are not part of the assertion.
|
||||
expect(statSync(join(dir, "cache")).mode & 0o777).toBe(0o700);
|
||||
expect(statSync(path).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it("leaves exactly the destination behind, with no temp sibling", async () => {
|
||||
const store = await MetadataStore.load(path);
|
||||
store.putCollection(sampleCollection());
|
||||
await store.save();
|
||||
// The atomic writer stages a temporary file and renames it into
|
||||
// place; on success nothing temporary is left in the directory.
|
||||
expect(readdirSync(join(dir, "cache"))).toEqual(["metadata.json"]);
|
||||
});
|
||||
|
||||
it("overwrites an existing store atomically and stays re-loadable", async () => {
|
||||
const first = await MetadataStore.load(path);
|
||||
first.userID = 1;
|
||||
first.putCollection(sampleCollection());
|
||||
await first.save();
|
||||
|
||||
const second = await MetadataStore.load(path);
|
||||
second.userID = 2;
|
||||
second.deleteCollection(12345);
|
||||
await second.save();
|
||||
|
||||
const reloaded = await MetadataStore.load(path);
|
||||
expect(reloaded.userID).toBe(2);
|
||||
expect(reloaded.getCollection(12345)).toBeUndefined();
|
||||
expect(readdirSync(join(dir, "cache"))).toEqual(["metadata.json"]);
|
||||
});
|
||||
|
||||
it("deletes a collection together with its file memberships", async () => {
|
||||
const store = await MetadataStore.load(path);
|
||||
store.putCollection(sampleCollection());
|
||||
store.putFile(sampleFile());
|
||||
store.deleteCollection(12345);
|
||||
expect(store.getCollection(12345)).toBeUndefined();
|
||||
expect(store.getFile(12345, 67890)).toBeUndefined();
|
||||
expect(store.listFiles(12345)).toEqual([]);
|
||||
});
|
||||
|
||||
it("scopes file records to their collection membership", async () => {
|
||||
// The same underlying file can be a member of two collections, each a
|
||||
// separate record with its own key. Storing one must not touch the
|
||||
// other, and lookups are per membership.
|
||||
const store = await MetadataStore.load(path);
|
||||
const inA = sampleFile();
|
||||
const inB: EnteFile = {
|
||||
...sampleFile(),
|
||||
collectionID: 55555,
|
||||
key: new Uint8Array([100, 101, 102]),
|
||||
};
|
||||
store.putFile(inA);
|
||||
store.putFile(inB);
|
||||
|
||||
expect(store.getFile(12345, 67890)?.key).toEqual(inA.key);
|
||||
expect(store.getFile(55555, 67890)?.key).toEqual(inB.key);
|
||||
expect(store.listFiles(12345)).toHaveLength(1);
|
||||
expect(store.listFiles(55555)).toHaveLength(1);
|
||||
|
||||
store.deleteFile(12345, 67890);
|
||||
expect(store.getFile(12345, 67890)).toBeUndefined();
|
||||
expect(store.getFile(55555, 67890)?.key).toEqual(inB.key);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user