Compare commits
1
Commits
3d78aacac2
...
3c46db56d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c46db56d5 |
@@ -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
|
||||
|
||||
+5
-2
@@ -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;
|
||||
|
||||
@@ -39,6 +39,7 @@ export type {
|
||||
Collection,
|
||||
CollectionType,
|
||||
EnteFile,
|
||||
EnteFileTombstone,
|
||||
FileBlob,
|
||||
FileMetadata,
|
||||
FileType,
|
||||
|
||||
+21
-3
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ export type {
|
||||
Collection,
|
||||
CollectionType,
|
||||
EnteFile,
|
||||
EnteFileTombstone,
|
||||
FileBlob,
|
||||
FileMetadata,
|
||||
FileType,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user