Compare commits

1 Commits
Author SHA1 Message Date
sneak 3d78aacac2 Carry file size, thumbnail size, and deletion flag through decryptFile (closes #37)
check / check (push) Successful in 25s
Foundation unit for the cache/API design. Three fields arrived on the wire
but decryptFile dropped them:

- `file.size` from `info.fileSize` and `thumbnail.size` from `info.thumbSize`,
  left `undefined` when the server omits `info`.
- `isDeleted` carried from the diff row onto `EnteFile`.

No caller change: `listFiles` still filters deleted rows before decrypt, so no
deleted row reaches decryptFile here. Surfacing a deleted file through
decryption belongs to the enumeration unit, issue 38, where the return shape
can be designed around where such a row actually flows.

Model: opus-4-8
2026-09-22 09:43:28 +00:00
7 changed files with 16 additions and 66 deletions
+4 -3
View File
@@ -21,9 +21,10 @@ Update the README API reference section to match the current implementation.
- 2026-09-22: Carried file size, thumbnail size, and the deletion flag through - 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 `decryptFile` (issue 37, foundation for the cache/API design). Live files now
populate `file.size`/`thumbnail.size` from the server's `info` (left populate `file.size`/`thumbnail.size` from the server's `info` (left
`undefined` when the server omits it), and a deleted file returns an `undefined` when the server omits it), and `isDeleted` is carried from the
`EnteFileTombstone``id`, `collectionID`, `updationTime`, `isDeleted` diff row onto `EnteFile`. No caller change: `listFiles` still filters deleted
instead of decrypting ciphertext the server no longer holds. rows before decrypting. Surfacing a tombstone through decryption belongs to
the enumeration unit (issue 38).
- 2026-08-10: Made `lint-once.test.ts` enforce what its header claims. It walked - 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 `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 `script/cibuild` — and a second `prettier --check .` could be added there with
+2 -5
View File
@@ -186,11 +186,8 @@ export class Client {
}>("/collections/v2/diff", { collectionID, sinceTime }); }>("/collections/v2/diff", { collectionID, sinceTime });
for (const raw of diff) { for (const raw of diff) {
// decryptFile returns a tombstone for a deleted file; the if (!raw.isDeleted) {
// diff keeps returning those, so drop them from the listing. allFiles.push(decryptFile(raw, collectionKey));
const file = decryptFile(raw, collectionKey);
if (!file.isDeleted) {
allFiles.push(file);
} }
if (raw.updationTime > sinceTime) { if (raw.updationTime > sinceTime) {
sinceTime = raw.updationTime; sinceTime = raw.updationTime;
-1
View File
@@ -39,7 +39,6 @@ export type {
Collection, Collection,
CollectionType, CollectionType,
EnteFile, EnteFile,
EnteFileTombstone,
FileBlob, FileBlob,
FileMetadata, FileMetadata,
FileType, FileType,
+2 -13
View File
@@ -8,7 +8,6 @@ import type {
Collection, Collection,
CollectionType, CollectionType,
EnteFile, EnteFile,
EnteFileTombstone,
FileMetadata, FileMetadata,
FileType, FileType,
KeyMaterial, KeyMaterial,
@@ -84,18 +83,7 @@ export const decryptCollection = (
export const decryptFile = ( export const decryptFile = (
raw: RawEnteFile, raw: RawEnteFile,
collectionKey: Uint8Array, collectionKey: Uint8Array,
): EnteFile | EnteFileTombstone => { ): EnteFile => {
// 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( const key = decryptBox(
fromBase64(raw.encryptedKey), fromBase64(raw.encryptedKey),
fromBase64(raw.keyDecryptionNonce), fromBase64(raw.keyDecryptionNonce),
@@ -141,6 +129,7 @@ export const decryptFile = (
size: raw.info?.thumbSize, size: raw.info?.thumbSize,
}, },
updationTime: raw.updationTime, updationTime: raw.updationTime,
isDeleted: raw.isDeleted,
}; };
}; };
-1
View File
@@ -3,7 +3,6 @@ export type {
Collection, Collection,
CollectionType, CollectionType,
EnteFile, EnteFile,
EnteFileTombstone,
FileBlob, FileBlob,
FileMetadata, FileMetadata,
FileType, FileType,
+3 -12
View File
@@ -48,18 +48,9 @@ export interface EnteFile {
file: FileBlob; file: FileBlob;
thumbnail: FileBlob; thumbnail: FileBlob;
updationTime: Microseconds; updationTime: Microseconds;
isDeleted?: false; // Set from the diff row's flag. Live files decode with it absent/false;
} // deleted rows are filtered out before decryptFile, so it is not set here.
isDeleted?: boolean;
// 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 // The key material a logged-in client holds, everything needed to decrypt
+5 -31
View File
@@ -196,24 +196,6 @@ const buildRawFile = (
}; };
}; };
// 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 // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -393,7 +375,6 @@ describe("model.decryptFile", () => {
}); });
const file = decryptFile(raw, collectionKey); const file = decryptFile(raw, collectionKey);
if (file.isDeleted) throw new Error("expected a live file");
expect(file.file.size).toBe(4096); expect(file.file.size).toBe(4096);
expect(file.thumbnail.size).toBe(512); expect(file.thumbnail.size).toBe(512);
@@ -408,28 +389,21 @@ describe("model.decryptFile", () => {
expect(raw.info).toBeUndefined(); expect(raw.info).toBeUndefined();
const file = decryptFile(raw, collectionKey); const file = decryptFile(raw, collectionKey);
if (file.isDeleted) throw new Error("expected a live file");
expect(file.file.size).toBeUndefined(); expect(file.file.size).toBeUndefined();
expect(file.thumbnail.size).toBeUndefined(); expect(file.thumbnail.size).toBeUndefined();
}); });
it("returns a minimal record for a tombstone without decrypting", () => { it("carries the deletion flag from the diff row", () => {
// A deleted file arrives with isDeleted set and no usable ciphertext. // The diff marks a deleted row with isDeleted; decryptFile copies it
// decryptFile must skip decryption entirely and return just the // onto the file so a caller can tell a deleted row from a live one.
// identity plus the flag — decrypting the empty payload would throw.
const masterKey = sodium.crypto_secretbox_keygen(); const masterKey = sodium.crypto_secretbox_keygen();
const { collectionKey } = buildRawCollection(masterKey); const { collectionKey } = buildRawCollection(masterKey);
const raw = buildTombstoneRawFile(); const raw = buildRawFile(collectionKey);
raw.isDeleted = true;
const file = decryptFile(raw, collectionKey); const file = decryptFile(raw, collectionKey);
expect(file.isDeleted).toBe(true); 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);
}); });
}); });