Carry file size, thumbnail size, and deletion flag through decryptFile (closes #37)
check / check (push) Successful in 24s

Foundation unit for the cache/API design. Three fields arrived on the wire
but decryptFile dropped them:

- Live files now set `file.size` from `info.fileSize` and `thumbnail.size`
  from `info.thumbSize`, left `undefined` when the server omits `info`.
- A deleted file is returned as a new `EnteFileTombstone` (`id`,
  `collectionID`, `updationTime`, `isDeleted: true`) with no decryption,
  since the server no longer holds ciphertext for it.

decryptFile now returns `EnteFile | EnteFileTombstone`. Rather than making
EnteFile's structural fields optional — which would force `?.`/guards across
every consumer under strict tsc and break the build — a tombstone is a
distinct minimal type, and `EnteFile.isDeleted?: false` is the discriminant
(a live file is never deleted). This keeps all existing consumers untouched;
they still receive fully-populated `EnteFile` values.

client.ts is the only direct caller: it now routes every row through
decryptFile and drops results whose `isDeleted` is set. listFiles still
returns live files only, so its observable behaviour is unchanged.

Model: opus-4-8
This commit is contained in:
2026-09-22 08:54:01 +00:00
parent d1d6cdd4f0
commit 3c46db56d5
7 changed files with 122 additions and 6 deletions
+76 -1
View File
@@ -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);
});
});