diff --git a/TODO.md b/TODO.md index efc43fb..bdcff58 100644 --- a/TODO.md +++ b/TODO.md @@ -18,6 +18,12 @@ Update the README API reference section to match the current implementation. # Completed Steps +- 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 + `undefined` when the server omits it), and a deleted file returns an + `EnteFileTombstone` — `id`, `collectionID`, `updationTime`, `isDeleted` — + instead of decrypting ciphertext the server no longer holds. - 2026-08-10: Made `lint-once.test.ts` enforce what its header claims. It walked `make check` only, so it never read `Dockerfile` — the image CI builds through `script/cibuild` — and a second `prettier --check .` could be added there with diff --git a/src/client.ts b/src/client.ts index a4d14ce..46c2305 100644 --- a/src/client.ts +++ b/src/client.ts @@ -186,8 +186,11 @@ export class Client { }>("/collections/v2/diff", { collectionID, sinceTime }); for (const raw of diff) { - if (!raw.isDeleted) { - allFiles.push(decryptFile(raw, collectionKey)); + // decryptFile returns a tombstone for a deleted file; the + // diff keeps returning those, so drop them from the listing. + const file = decryptFile(raw, collectionKey); + if (!file.isDeleted) { + allFiles.push(file); } if (raw.updationTime > sinceTime) { sinceTime = raw.updationTime; diff --git a/src/index.ts b/src/index.ts index c53ceb9..85cafb9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,7 @@ export type { Collection, CollectionType, EnteFile, + EnteFileTombstone, FileBlob, FileMetadata, FileType, diff --git a/src/model/decrypt.ts b/src/model/decrypt.ts index fea8f99..00e6814 100644 --- a/src/model/decrypt.ts +++ b/src/model/decrypt.ts @@ -8,6 +8,7 @@ import type { Collection, CollectionType, EnteFile, + EnteFileTombstone, FileMetadata, FileType, KeyMaterial, @@ -83,7 +84,18 @@ export const decryptCollection = ( export const decryptFile = ( raw: RawEnteFile, collectionKey: Uint8Array, -): EnteFile => { +): EnteFile | EnteFileTombstone => { + // A deleted file has no ciphertext to decrypt; return the identity and + // the flag, and touch none of the encrypted fields. + if (raw.isDeleted) { + return { + id: raw.id, + collectionID: raw.collectionID, + updationTime: raw.updationTime, + isDeleted: true, + }; + } + const key = decryptBox( fromBase64(raw.encryptedKey), fromBase64(raw.keyDecryptionNonce), @@ -120,8 +132,14 @@ export const decryptFile = ( metadata, magicMetadata, pubMagicMetadata, - file: { decryptionHeader: raw.file.decryptionHeader }, - thumbnail: { decryptionHeader: raw.thumbnail.decryptionHeader }, + file: { + decryptionHeader: raw.file.decryptionHeader, + size: raw.info?.fileSize, + }, + thumbnail: { + decryptionHeader: raw.thumbnail.decryptionHeader, + size: raw.info?.thumbSize, + }, updationTime: raw.updationTime, }; }; diff --git a/src/model/index.ts b/src/model/index.ts index 9a7027e..051dc50 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -3,6 +3,7 @@ export type { Collection, CollectionType, EnteFile, + EnteFileTombstone, FileBlob, FileMetadata, FileType, diff --git a/src/model/types.ts b/src/model/types.ts index ceb6c79..bbc4499 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -48,6 +48,18 @@ export interface EnteFile { file: FileBlob; thumbnail: FileBlob; updationTime: Microseconds; + isDeleted?: false; +} + +// A deleted file as the sync diff reports it. The server keeps returning the +// row so clients can drop it, but it carries no ciphertext to decrypt, so a +// tombstone is only an identity plus the flag. isDeleted discriminates it +// from EnteFile, so callers narrow on that field before reaching for content. +export interface EnteFileTombstone { + id: number; + collectionID: number; + updationTime: Microseconds; + isDeleted: true; } // The key material a logged-in client holds, everything needed to decrypt diff --git a/test/model/decrypt.test.ts b/test/model/decrypt.test.ts index 13af5c2..ecb7615 100644 --- a/test/model/decrypt.test.ts +++ b/test/model/decrypt.test.ts @@ -145,7 +145,12 @@ const buildSharedRawCollection = ( const buildRawFile = ( collectionKey: Uint8Array, - opts?: { title?: string; fileType?: number; creationTime?: number }, + opts?: { + title?: string; + fileType?: number; + creationTime?: number; + info?: { fileSize?: number; thumbSize?: number }; + }, ): RawEnteFile => { const fileKey = sodium.crypto_secretbox_keygen(); const { ciphertext: encFileKey, nonce: fileKeyNonce } = secretboxEncrypt( @@ -186,10 +191,29 @@ const buildRawFile = ( }, file: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) }, thumbnail: { decryptionHeader: toBase64(sodium.randombytes_buf(24)) }, + info: opts?.info, updationTime: 1700000000000000, }; }; +// A deleted file as the sync diff returns it: isDeleted is set and the +// encrypted payload fields are empty, because the server no longer holds +// the content to encrypt. Attempting to decrypt these would throw, which +// is exactly what the tombstone path must avoid. Do not "fix" this fixture +// by giving it real ciphertext: its emptiness is what the wire sends. +const buildTombstoneRawFile = (): RawEnteFile => ({ + id: 201, + collectionID: 100, + ownerID: 1, + encryptedKey: "", + keyDecryptionNonce: "", + metadata: { encryptedData: "", decryptionHeader: "" }, + file: { decryptionHeader: "" }, + thumbnail: { decryptionHeader: "" }, + updationTime: 1700000000000001, + isDeleted: true, +}); + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -357,4 +381,55 @@ describe("model.decryptFile", () => { expect(() => decryptFile(raw, wrongKey)).toThrow(); }); + + it("carries the file and thumbnail byte sizes from info", () => { + // The server reports the encrypted-blob sizes in `info`; the cache + // needs them without a HEAD request, so decryptFile must copy them + // onto the file and thumbnail blobs. + const masterKey = sodium.crypto_secretbox_keygen(); + const { collectionKey } = buildRawCollection(masterKey); + const raw = buildRawFile(collectionKey, { + info: { fileSize: 4096, thumbSize: 512 }, + }); + + const file = decryptFile(raw, collectionKey); + if (file.isDeleted) throw new Error("expected a live file"); + + expect(file.file.size).toBe(4096); + expect(file.thumbnail.size).toBe(512); + }); + + it("leaves the sizes undefined when the server omits info", () => { + // Older files predate the info field; the sizes must stay undefined + // rather than become 0, so callers can tell "unknown" from "empty". + const masterKey = sodium.crypto_secretbox_keygen(); + const { collectionKey } = buildRawCollection(masterKey); + const raw = buildRawFile(collectionKey); + expect(raw.info).toBeUndefined(); + + const file = decryptFile(raw, collectionKey); + if (file.isDeleted) throw new Error("expected a live file"); + + expect(file.file.size).toBeUndefined(); + expect(file.thumbnail.size).toBeUndefined(); + }); + + it("returns a minimal record for a tombstone without decrypting", () => { + // A deleted file arrives with isDeleted set and no usable ciphertext. + // decryptFile must skip decryption entirely and return just the + // identity plus the flag — decrypting the empty payload would throw. + const masterKey = sodium.crypto_secretbox_keygen(); + const { collectionKey } = buildRawCollection(masterKey); + const raw = buildTombstoneRawFile(); + + const file = decryptFile(raw, collectionKey); + + expect(file.isDeleted).toBe(true); + expect(file.id).toBe(201); + expect(file.collectionID).toBe(100); + expect(file.updationTime).toBe(1700000000000001); + // No decrypted content is carried for a tombstone. + expect("metadata" in file).toBe(false); + expect("key" in file).toBe(false); + }); });