Compare commits

..
1 Commits
Author SHA1 Message Date
sneak f788c0adc8 Harden the retry classifier and pin per-attempt deadlines (closes #80)
check / check (push) Successful in 31s
A POST or PUT is replayed only when every errno in the cause chain is a
connect errno, and postJSON/putJSON no longer follow redirects, so a
redirect is an ApiError that is not retried. getRetryOptions() returns
a copy. New tests pin every errno the classifier names, the cause-chain
depth limit, a two-error cycle, and a fresh deadline per attempt for
every retrying entry point. The README endpoint list is now the one
place naming the requests the replay rule covers; code comments point
to it.

Model: opus-5-5
2026-09-22 23:58:27 +00:00
25 changed files with 94 additions and 736 deletions
+2 -7
View File
@@ -426,18 +426,13 @@ decides how to persist sessions.
`client.toJSON()` returns a `ClientSnapshot` (a plain serializable object with `client.toJSON()` returns a `ClientSnapshot` (a plain serializable object with
base64-encoded keys) that the consumer can write to disk, a database, or base64-encoded keys) that the consumer can write to disk, a database, or
whatever else fits their use case. `Client.fromJSON(snapshot)` restores a whatever else fits their use case. `Client.fromJSON(snapshot)` restores a
working client from that snapshot without re-authenticating; it checks every working client from that snapshot without re-authenticating.
field and each key's length first, and throws an error naming the bad field.
`client.logout()` clears the token and zeroes the key buffers in place; every
later call on that client throws.
The CLI stores the snapshot at the platform-appropriate data directory via The CLI stores the snapshot at the platform-appropriate data directory via
`env-paths`: `~/Library/Application Support/quak/session.json` on macOS, `env-paths`: `~/Library/Application Support/quak/session.json` on macOS,
`$XDG_DATA_HOME/quak/session.json` on Linux. The file is written with mode `$XDG_DATA_HOME/quak/session.json` on Linux. The file is written with mode
`0600`. The key material is stored in cleartext in the JSON; treat this file as `0600`. The key material is stored in cleartext in the JSON; treat this file as
you would treat the password itself. A missing file is reported as "not logged you would treat the password itself.
in"; a file that exists but is corrupt is reported as such, naming the bad
field. Both exit with status 1.
### CLI surface ### CLI surface
-18
View File
@@ -25,24 +25,6 @@ Tag v1.0.0.
termination, and a fresh deadline per attempt for every retrying entry point. termination, and a fresh deadline per attempt for every retrying entry point.
The README's endpoint list is the one place that names the requests the replay The README's endpoint list is the one place that names the requests the replay
rule covers. rule covers.
- 2026-09-22: Dropped the deprecated `@types/libsodium-wrappers-sumo` stub from
`devDependencies` (issue 27). It shipped no declarations; the types come from
`libsodium-wrappers-sumo` itself. `yarn.lock` regenerated by `yarn remove`.
- 2026-09-22: Hardened the client session lifecycle (issue 10).
`Client.fromJSON` checks every snapshot field and each key's decoded length
and names the bad field; `toJSON` reads the token through
`ApiClient.getAuthToken` and throws when there is none; `logout` zeroes the
key buffers, and `collectionsSince` re-checks for logout after its request so
it never decrypts with zeroed keys. The CLI reports a corrupt session file
separately from a missing one (`src/cli-session.ts`).
- 2026-09-22: Sanitized file names taken from server metadata (issue 9). A new
`src/filename.ts` holds the one sanitizer, used by `quak get`/`get-thumb`
without `--out`, `downloadFile`/`downloadThumbnail` without `outPath`, and the
backup and metadata backup trees; it removes separators, control characters,
leading dots and Windows device names, and falls back to a name built from the
ID for an empty title. Originals-cache extensions are letters and digits only,
else `.bin`. A user-supplied path is used as is. `decryptFile` reads a missing
or non-string title as "" and rejects metadata that is not a JSON object.
- 2026-09-22: Rewrote the README API reference (and the Getting Started / usage - 2026-09-22: Rewrote the README API reference (and the Getting Started / usage
snippets) to match the shipped cache/API library on `next` (issue 53, issue snippets) to match the shipped cache/API library on `next` (issue 53, issue
13). Documented `Library.open` and its options, the default-read vs `fresh()` 13). Documented `Library.open` and its options, the default-read vs `fresh()`
+20 -16
View File
@@ -2,7 +2,13 @@
import { input, password as passwordPrompt } from "@inquirer/prompts"; import { input, password as passwordPrompt } from "@inquirer/prompts";
import { stdout, stderr } from "node:process"; import { stdout, stderr } from "node:process";
import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { Command } from "commander"; import { Command } from "commander";
import envPaths from "env-paths"; import envPaths from "env-paths";
@@ -16,7 +22,6 @@ import {
thumbnailName, thumbnailName,
} from "../src/cli-output.js"; } from "../src/cli-output.js";
import { freshCollections, freshFiles, freshFile } from "../src/cli-read.js"; import { freshCollections, freshFiles, freshFile } from "../src/cli-read.js";
import { loadSession } from "../src/cli-session.js";
import { runMetadataBackup } from "../src/metadata-backup.js"; import { runMetadataBackup } from "../src/metadata-backup.js";
import { import {
listMissingThumbnails, listMissingThumbnails,
@@ -26,6 +31,15 @@ import {
const paths = envPaths("quak", { suffix: "" }); const paths = envPaths("quak", { suffix: "" });
const sessionPath = join(paths.data, "session.json"); const sessionPath = join(paths.data, "session.json");
const loadSession = (): ClientSnapshot | null => {
if (!existsSync(sessionPath)) return null;
try {
return JSON.parse(readFileSync(sessionPath, "utf-8")) as ClientSnapshot;
} catch {
return null;
}
};
const saveSession = (snapshot: ClientSnapshot): void => { const saveSession = (snapshot: ClientSnapshot): void => {
mkdirSync(paths.data, { recursive: true, mode: 0o700 }); mkdirSync(paths.data, { recursive: true, mode: 0o700 });
writeFileSync(sessionPath, JSON.stringify(snapshot, null, 2), { writeFileSync(sessionPath, JSON.stringify(snapshot, null, 2), {
@@ -34,23 +48,14 @@ const saveSession = (snapshot: ClientSnapshot): void => {
}; };
const requireSession = (): Client => { const requireSession = (): Client => {
let client: Client | null; const snapshot = loadSession();
try { if (!snapshot) {
client = loadSession(sessionPath);
} catch (err) {
stderr.write(
`${err instanceof Error ? err.message : err}\n` +
`Run "quak logout" and then "quak login" to replace it.\n`,
);
process.exit(1);
}
if (!client) {
stderr.write( stderr.write(
`Not logged in. Run "quak login" first.\nSession file: ${sessionPath}\n`, `Not logged in. Run "quak login" first.\nSession file: ${sessionPath}\n`,
); );
process.exit(1); process.exit(1);
} }
return client; return Client.fromJSON(snapshot);
}; };
const prompt = async (message: string): Promise<string> => input({ message }); const prompt = async (message: string): Promise<string> => input({ message });
@@ -152,8 +157,7 @@ program
program program
.command("whoami") .command("whoami")
.description("Print the logged-in account") .description("Print the logged-in account")
.action(async () => { .action(() => {
await init();
const client = requireSession(); const client = requireSession();
const info = client.whoami(); const info = client.whoami();
stdout.write(JSON.stringify(info) + "\n"); stdout.write(JSON.stringify(info) + "\n");
+1
View File
@@ -29,6 +29,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.38.0", "@eslint/js": "9.38.0",
"@types/libsodium-wrappers-sumo": "0.8.2",
"@types/node": "22.18.13", "@types/node": "22.18.13",
"eslint": "9.38.0", "eslint": "9.38.0",
"prettier": "3.8.1", "prettier": "3.8.1",
-4
View File
@@ -139,10 +139,6 @@ export class ApiClient {
this.token = undefined; this.token = undefined;
} }
getAuthToken(): string | undefined {
return this.token;
}
// The policy this client was configured with, so that a caller wrapping a // The policy this client was configured with, so that a caller wrapping a
// whole operation in its own `withRetry` — the download layer — runs under // whole operation in its own `withRetry` — the download layer — runs under
// the same settings rather than under the library defaults. // the same settings rather than under the library defaults.
+11 -8
View File
@@ -40,9 +40,8 @@ import {
symlinkSync, symlinkSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { basename, dirname, join, relative } from "node:path"; import { basename, dirname, extname, join, relative } from "node:path";
import { safeExtension, sanitizeFileName } from "./filename.js";
import type { Collection, EnteFile } from "./model/types.js"; import type { Collection, EnteFile } from "./model/types.js";
export type ProgressCallback = (message: string) => void; export type ProgressCallback = (message: string) => void;
@@ -109,11 +108,16 @@ interface FailureEntry {
const LEDGER_VERSION = 1; const LEDGER_VERSION = 1;
const sanitizePath = (name: string): string =>
name.replace(/[/\\:*?"<>|]/g, "_").replace(/^\.+/, "_");
// The originals/ filename for a file: `<id><ext>`, the extension taken from the // The originals/ filename for a file: `<id><ext>`, the extension taken from the
// title (or `.bin`). Matches the content cache's own naming so a present check // title (or `.bin`). Matches the content cache's own naming so a present check
// lines up with what a fetch would write. // lines up with what a fetch would write.
const originalName = (file: EnteFile): string => const originalName = (file: EnteFile): string => {
`${file.id}${safeExtension(file.metadata.title)}`; const ext = extname(file.metadata.title || "") || ".bin";
return `${file.id}${ext}`;
};
// A regular file with content is treated as complete. A zero-byte file is not: // A regular file with content is treated as complete. A zero-byte file is not:
// it is the shape an aborted write leaves and must be re-fetched. // it is the shape an aborted write leaves and must be re-fetched.
@@ -366,7 +370,7 @@ export const runBackup = async (
// Then the per-collection symlink trees and JSON. // Then the per-collection symlink trees and JSON.
for (const c of collections) { for (const c of collections) {
const colDirName = sanitizeFileName(c.name, `collection-${c.id}`); const colDirName = sanitizePath(c.name || `collection-${c.id}`);
const colDir = join(collectionsDir, colDirName); const colDir = join(collectionsDir, colDirName);
mkdirSync(colDir, { recursive: true }); mkdirSync(colDir, { recursive: true });
@@ -377,9 +381,8 @@ export const runBackup = async (
if (!includeOriginals) continue; if (!includeOriginals) continue;
const orig = join(originalsDir, originalName(file)); const orig = join(originalsDir, originalName(file));
if (!isPresent(orig)) continue; if (!isPresent(orig)) continue;
const linkName = sanitizeFileName( const linkName = sanitizePath(
file.metadata.title, file.metadata.title || `file-${file.id}`,
`file-${file.id}`,
); );
const linkPath = join(colDir, linkName); const linkPath = join(colDir, linkName);
try { try {
+3 -6
View File
@@ -9,7 +9,6 @@
// `metadata.title`, and issue #52 requires that output stay byte-identical, so // `metadata.title`, and issue #52 requires that output stay byte-identical, so
// the commands shape their output from the raw `EnteFile` through here. // the commands shape their output from the raw `EnteFile` through here.
import { sanitizeFileName } from "./filename.js";
import type { EnteFile, FileType, Microseconds } from "./model/types.js"; import type { EnteFile, FileType, Microseconds } from "./model/types.js";
// One row of `quak files --json`. // One row of `quak files --json`.
@@ -33,11 +32,9 @@ export const fileListRow = (file: EnteFile): FileListRow => ({
export const fileListLine = (file: EnteFile): string => export const fileListLine = (file: EnteFile): string =>
`${file.id}\t${file.metadata.fileType}\t${file.metadata.title}`; `${file.id}\t${file.metadata.fileType}\t${file.metadata.title}`;
// Default output path for `quak get` when `--out` is not given. The title comes // Default output path for `quak get` when `--out` is not given.
// from the server, so it is sanitized; `--out` is the user's and is used as is. export const originalName = (file: EnteFile): string => file.metadata.title;
export const originalName = (file: EnteFile): string =>
sanitizeFileName(file.metadata.title, `file-${file.id}`);
// Default output path for `quak get-thumb` when `--out` is not given. // Default output path for `quak get-thumb` when `--out` is not given.
export const thumbnailName = (file: EnteFile): string => export const thumbnailName = (file: EnteFile): string =>
`thumb_${originalName(file)}`; `thumb_${file.metadata.title}`;
-26
View File
@@ -1,26 +0,0 @@
// How the CLI reads its saved session file back into a `Client`.
//
// A missing file means "not logged in" and returns null. A file that exists but
// cannot be read back into a client (bad JSON, a missing field, a key of the
// wrong length) throws an error saying the session file is corrupt, so the CLI
// can tell the user which of the two it is. Needs `init()` first.
import { existsSync, readFileSync } from "node:fs";
import type { ApiClientOptions } from "./api/client.js";
import { Client } from "./client.js";
export const loadSession = (
path: string,
apiOptions?: ApiClientOptions,
): Client | null => {
if (!existsSync(path)) return null;
try {
return Client.fromJSON(
JSON.parse(readFileSync(path, "utf-8")),
apiOptions,
);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new Error(`Session file ${path} is corrupt: ${reason}`);
}
};
+11 -62
View File
@@ -125,57 +125,18 @@ export class Client {
); );
} }
// Restore a client from a `toJSON()` snapshot. The snapshot usually comes static fromJSON(
// straight from `JSON.parse` of a file on disk, so every field is checked snapshot: ClientSnapshot,
// before use; a bad one throws an error naming it. Needs `init()` first. apiOptions?: ApiClientOptions,
static fromJSON(snapshot: unknown, apiOptions?: ApiClientOptions): Client { ): Client {
const invalid = (field: string, problem: string): Error => const api = new ApiClient({ ...apiOptions, authToken: snapshot.token });
new Error(`Invalid session data: ${field} ${problem}`);
if (typeof snapshot !== "object" || snapshot === null) {
throw new Error("Invalid session data: not a JSON object");
}
const s = snapshot as Record<string, unknown>;
for (const field of ["email", "token"]) {
if (typeof s[field] !== "string" || s[field] === "") {
throw invalid(field, "must be a non-empty string");
}
}
if (!Number.isInteger(s.userID)) {
throw invalid("userID", "must be an integer");
}
const key = (field: string): Uint8Array => {
const value = s[field];
if (typeof value !== "string") {
throw invalid(field, "must be a base64 string");
}
let bytes: Uint8Array;
try {
bytes = fromBase64(value);
} catch {
throw invalid(field, "is not valid base64");
}
// The master key (secretbox) and the key pair (box) are all 32 bytes.
if (bytes.length !== 32) {
throw invalid(
field,
`must decode to 32 bytes, got ${bytes.length}`,
);
}
return bytes;
};
const api = new ApiClient({
...apiOptions,
authToken: s.token as string,
});
return new Client( return new Client(
api, api,
s.email as string, snapshot.email,
s.userID as number, snapshot.userID,
key("masterKey"), fromBase64(snapshot.masterKey),
key("secretKey"), fromBase64(snapshot.secretKey),
key("publicKey"), fromBase64(snapshot.publicKey),
); );
} }
@@ -203,29 +164,19 @@ export class Client {
toJSON(): ClientSnapshot { toJSON(): ClientSnapshot {
this.assertLoggedIn(); this.assertLoggedIn();
const token = this.api.getAuthToken();
if (!token) {
throw new Error("Cannot serialize client: it has no auth token");
}
return { return {
email: this.email, email: this.email,
userID: this.userID, userID: this.userID,
token, token: this.api["token"]!,
masterKey: toBase64(this.masterKey), masterKey: toBase64(this.masterKey),
secretKey: toBase64(this.secretKey), secretKey: toBase64(this.secretKey),
publicKey: toBase64(this.publicKey), publicKey: toBase64(this.publicKey),
}; };
} }
// Zeroes the key buffers in place, so any copy of the reference held
// elsewhere is wiped too. Every method checks `assertLoggedIn` before
// touching the keys, so nothing decrypts with the zeroed keys.
logout(): void { logout(): void {
this.loggedOut = true; this.loggedOut = true;
this.api.clearAuthToken(); this.api.clearAuthToken();
this.masterKey.fill(0);
this.secretKey.fill(0);
this.publicKey.fill(0);
} }
// Enumerate collections changed since `sinceTime`. Live collections are // Enumerate collections changed since `sinceTime`. Live collections are
@@ -241,8 +192,6 @@ export class Client {
const { collections: raws } = await this.api.getJSON<{ const { collections: raws } = await this.api.getJSON<{
collections: RawCollection[]; collections: RawCollection[];
}>("/collections/v2", { sinceTime: args.sinceTime }); }>("/collections/v2", { sinceTime: args.sinceTime });
// logout() may have zeroed the keys while the request was in flight.
this.assertLoggedIn();
const collections: Collection[] = []; const collections: Collection[] = [];
const deleted: number[] = []; const deleted: number[] = [];
+2 -8
View File
@@ -11,7 +11,6 @@ import {
streamTagFinal, streamTagFinal,
} from "../crypto/index.js"; } from "../crypto/index.js";
import { TruncatedStreamError } from "../errors.js"; import { TruncatedStreamError } from "../errors.js";
import { sanitizeFileName } from "../filename.js";
import { withRetry } from "../retry.js"; import { withRetry } from "../retry.js";
import type { ApiClient } from "../api/client.js"; import type { ApiClient } from "../api/client.js";
import type { EnteFile } from "../model/types.js"; import type { EnteFile } from "../model/types.js";
@@ -287,10 +286,7 @@ export const downloadFile = async (
outPath?: string, outPath?: string,
onProgress?: ProgressCallback, onProgress?: ProgressCallback,
): Promise<DownloadResult> => { ): Promise<DownloadResult> => {
// `outPath` is the caller's and is used as is; the title is the server's const resolvedPath = outPath ?? file.metadata.title;
// and is sanitized so it can only name a file in the current directory.
const resolvedPath =
outPath ?? sanitizeFileName(file.metadata.title, `file-${file.id}`);
const header = fromBase64(file.file.decryptionHeader); const header = fromBase64(file.file.decryptionHeader);
const bytesWritten = await fetchAndDecrypt( const bytesWritten = await fetchAndDecrypt(
api, api,
@@ -309,9 +305,7 @@ export const downloadThumbnail = async (
outPath?: string, outPath?: string,
onProgress?: ProgressCallback, onProgress?: ProgressCallback,
): Promise<DownloadResult> => { ): Promise<DownloadResult> => {
const resolvedPath = const resolvedPath = outPath ?? `thumb_${file.metadata.title}`;
outPath ??
`thumb_${sanitizeFileName(file.metadata.title, `file-${file.id}`)}`;
const header = fromBase64(file.thumbnail.decryptionHeader); const header = fromBase64(file.thumbnail.decryptionHeader);
const bytesWritten = await fetchAndDecrypt( const bytesWritten = await fetchAndDecrypt(
api, api,
-37
View File
@@ -1,37 +0,0 @@
// File names built from server-supplied metadata.
//
// A file's title and a collection's name are decrypted from data the server
// hands us, and quak does not trust the server. Any name taken from them and
// used on disk goes through here, so it can only ever name one file inside the
// directory the caller chose: never a path, never `..`, never hidden, never a
// Windows device name.
//
// A path the user typed (`--out`, `outPath`) is not passed through here: the
// caller is trusted, the server is not.
import { extname } from "node:path";
// Path separators, characters Windows forbids in file names, and control
// characters (NUL included).
// eslint-disable-next-line no-control-regex
const UNSAFE_CHARACTERS = /[/\\:*?"<>|\x00-\x1f\x7f]/g;
// Names Windows reserves for devices, with or without an extension.
const RESERVED_DEVICE_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i;
// `name` made safe to use as a single file name. Each unsafe character becomes
// `_`, a leading run of dots becomes one `_`, and a device name gets a leading
// `_`. A name with none of these comes back unchanged. An empty name becomes
// `fallback`, which the caller derives from the record's ID.
export const sanitizeFileName = (name: string, fallback: string): string => {
if (name === "") return fallback;
const cleaned = name.replace(UNSAFE_CHARACTERS, "_").replace(/^\.+/, "_");
return RESERVED_DEVICE_NAME.test(cleaned) ? `_${cleaned}` : cleaned;
};
// The extension of `title` (".jpg"), or ".bin" when it has none or it holds
// anything but letters and digits.
export const safeExtension = (title: string): string => {
const ext = extname(title);
return /^\.[A-Za-z0-9]+$/.test(ext) ? ext : ".bin";
};
+4 -3
View File
@@ -40,7 +40,6 @@ import {
downloadThumbnail, downloadThumbnail,
type ProgressCallback, type ProgressCallback,
} from "../download/index.js"; } from "../download/index.js";
import { safeExtension } from "../filename.js";
import type { EnteFile } from "../model/types.js"; import type { EnteFile } from "../model/types.js";
import type { Priority, RequestPools } from "./pools.js"; import type { Priority, RequestPools } from "./pools.js";
@@ -210,8 +209,10 @@ class AbortDrop extends Error {
} }
} }
const originalName = (file: EnteFile): string => const originalName = (file: EnteFile): string => {
`${file.id}${safeExtension(file.metadata.title)}`; const ext = extname(file.metadata.title || "") || ".bin";
return `${file.id}${ext}`;
};
// The fileID a cache filename encodes, or undefined when the name is not one // The fileID a cache filename encodes, or undefined when the name is not one
// the cache writes (`<digits><ext>`). // the cache writes (`<digits><ext>`).
+4 -2
View File
@@ -4,7 +4,6 @@ import * as jpeg from "jpeg-js";
import exifReader from "exif-reader"; import exifReader from "exif-reader";
import type { Client } from "./client.js"; import type { Client } from "./client.js";
import type { Library, Photo } from "./library/index.js"; import type { Library, Photo } from "./library/index.js";
import { sanitizeFileName } from "./filename.js";
import { fetchMLData } from "./mldata-fetch.js"; import { fetchMLData } from "./mldata-fetch.js";
import type { EnteFile } from "./model/types.js"; import type { EnteFile } from "./model/types.js";
@@ -15,6 +14,9 @@ export interface MetadataBackupOptions {
onProgress?: ProgressCallback; onProgress?: ProgressCallback;
} }
const sanitizePath = (name: string): string =>
name.replace(/[/\\:*?"<>|]/g, "_").replace(/^\.+/, "_");
// Extract the raw EXIF APP1 segment from JPEG bytes. Returns the EXIF // Extract the raw EXIF APP1 segment from JPEG bytes. Returns the EXIF
// data buffer (starting after the APP1 length field, at the "Exif\0\0" // data buffer (starting after the APP1 length field, at the "Exif\0\0"
// header) or undefined if no APP1 marker is found. // header) or undefined if no APP1 marker is found.
@@ -149,7 +151,7 @@ export const runMetadataBackup = async (
const col = lib.getCollection(album.collectionID); const col = lib.getCollection(album.collectionID);
if (!col) continue; if (!col) continue;
const dirName = `${col.id}-${sanitizeFileName(col.name, "unnamed")}`; const dirName = `${col.id}-${sanitizePath(col.name || "unnamed")}`;
const colDir = join(outDir, "collections", dirName); const colDir = join(outDir, "collections", dirName);
mkdirSync(colDir, { recursive: true }); mkdirSync(colDir, { recursive: true });
+1 -10
View File
@@ -98,18 +98,9 @@ export const decryptFile = (
key, key,
); );
const metadataJSON = JSON.parse(new TextDecoder().decode(metadataBytes)); const metadataJSON = JSON.parse(new TextDecoder().decode(metadataBytes));
if (
typeof metadataJSON !== "object" ||
metadataJSON === null ||
Array.isArray(metadataJSON)
) {
throw new Error(`file ${raw.id}: metadata is not a JSON object`);
}
const metadata: FileMetadata = { const metadata: FileMetadata = {
// The server controls this JSON: a title that is missing or not a title: metadataJSON.title ?? "",
// string becomes "", never an arbitrary value.
title: typeof metadataJSON.title === "string" ? metadataJSON.title : "",
fileType: parseFileType(metadataJSON.fileType ?? -1), fileType: parseFileType(metadataJSON.fileType ?? -1),
creationTime: metadataJSON.creationTime ?? 0, creationTime: metadataJSON.creationTime ?? 0,
modificationTime: metadataJSON.modificationTime ?? 0, modificationTime: metadataJSON.modificationTime ?? 0,
+8 -13
View File
@@ -88,21 +88,18 @@ const MAX_CAUSE_DEPTH = 8;
// errno on the error it throws — it hangs the underlying socket error off // errno on the error it throws — it hangs the underlying socket error off
// `cause`, sometimes more than one level down — so a classifier that only read // `cause`, sometimes more than one level down — so a classifier that only read
// the top-level error would see a bare `Error` and call every dropped // the top-level error would see a bare `Error` and call every dropped
// connection permanent. `complete` is false when the walk stopped at the // connection permanent.
// depth limit with more of the chain still below it. const causeCodes = (err: unknown): string[] => {
const causeCodes = (err: unknown): { codes: string[]; complete: boolean } => {
const codes: string[] = []; const codes: string[] = [];
let current: unknown = err; let current: unknown = err;
for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) { for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) {
if (current === null || typeof current !== "object") { if (current === null || typeof current !== "object") break;
return { codes, complete: true };
}
const { code, cause } = current as { code?: unknown; cause?: unknown }; const { code, cause } = current as { code?: unknown; cause?: unknown };
if (typeof code === "string") codes.push(code); if (typeof code === "string") codes.push(code);
if (cause === current) return { codes, complete: true }; if (cause === current) break;
current = cause; current = cause;
} }
return { codes, complete: current === null || typeof current !== "object" }; return codes;
}; };
const isAbort = (err: unknown): boolean => { const isAbort = (err: unknown): boolean => {
@@ -148,7 +145,7 @@ export const isRetryable = (err: unknown): boolean => {
// have succeeded; the cost of the imprecision is bounded by the attempt // have succeeded; the cost of the imprecision is bounded by the attempt
// count. // count.
if (err instanceof TypeError) return true; if (err instanceof TypeError) return true;
return causeCodes(err).codes.some((code) => TRANSPORT_CODES.has(code)); return causeCodes(err).some((code) => TRANSPORT_CODES.has(code));
}; };
// Could the first attempt already have taken effect on the server? // Could the first attempt already have taken effect on the server?
@@ -167,13 +164,11 @@ export const isRetryable = (err: unknown): boolean => {
// acted on. A routing errno can be delivered on an established socket. A // acted on. A routing errno can be delivered on an established socket. A
// deadline says nothing at all about the server's state. So every errno in the // deadline says nothing at all about the server's state. So every errno in the
// cause chain must be a connect errno: one other errno anywhere in the chain // cause chain must be a connect errno: one other errno anywhere in the chain
// is doubt, and doubt is not replayed. A chain longer than the walk is doubt // is doubt, and doubt is not replayed.
// too: the links below the limit were never read.
export const isSafeToReplay = (err: unknown): boolean => { export const isSafeToReplay = (err: unknown): boolean => {
const { codes, complete } = causeCodes(err); const codes = causeCodes(err);
return ( return (
isRetryable(err) && isRetryable(err) &&
complete &&
codes.length > 0 && codes.length > 0 &&
codes.every((code) => CONNECT_CODES.has(code)) codes.every((code) => CONNECT_CODES.has(code))
); );
+2 -53
View File
@@ -36,7 +36,6 @@ import {
lstatSync, lstatSync,
mkdirSync, mkdirSync,
mkdtempSync, mkdtempSync,
readdirSync,
readFileSync, readFileSync,
readlinkSync, readlinkSync,
rmSync, rmSync,
@@ -108,29 +107,6 @@ class MockClient {
} }
} }
// A server that names an album and a file so as to climb out of the backup
// directory.
class HostileClient extends MockClient {
override async collectionsSince(): Promise<CollectionsPage> {
const page = await super.collectionsSince();
return {
...page,
collections: page.collections.length
? [collection(3, "../escape")]
: [],
};
}
override async filesSince(args: {
collectionID: number;
}): Promise<FilesPage> {
const files =
args.collectionID === 3
? [file(300, 3, "../../.ssh/authorized_keys")]
: [];
return { files, deleted: [], cursor: 1 };
}
}
// A content source that writes byte buffers of the expected length and can be // A content source that writes byte buffers of the expected length and can be
// told to fail one fileID's original, to exercise per-file resilience. // told to fail one fileID's original, to exercise per-file resilience.
interface StubSource extends ContentSource { interface StubSource extends ContentSource {
@@ -160,12 +136,9 @@ const stubSource = (): StubSource => {
let root: string; let root: string;
const openLibrary = ( const openLibrary = (source: ContentSource): Promise<Library> =>
source: ContentSource,
client: MockClient = new MockClient(),
): Promise<Library> =>
Library.open({ Library.open({
client, client: new MockClient(),
cacheDirectory: join(root, "cache"), cacheDirectory: join(root, "cache"),
contentSource: source, contentSource: source,
refreshIntervalSeconds: 3600, refreshIntervalSeconds: 3600,
@@ -270,30 +243,6 @@ describe("lib.backup", () => {
lib.close(); lib.close();
}); });
it("keeps server-supplied album and file names inside the backup", async () => {
const lib = await openLibrary(stubSource(), new HostileClient());
const outDir = join(root, "backup");
const result = await lib.backup({ downloadDirectory: outDir });
expect(result.failed).toBe(0);
// The title has no usable extension, so the original is `.bin`.
expect(existsSync(join(outDir, "originals", "300.bin"))).toBe(true);
const link = join(
outDir,
"collections",
"__escape",
"__.._.ssh_authorized_keys",
);
expect(lstatSync(link).isSymbolicLink()).toBe(true);
expect(existsSync(join(outDir, "collections", "__escape.json"))).toBe(
true,
);
// Nothing landed beside or above the backup directory.
expect(readdirSync(root).sort()).toEqual(["backup", "cache"]);
lib.close();
});
it("is an idempotent no-op when every original is already present", async () => { it("is an idempotent no-op when every original is already present", async () => {
const source = stubSource(); const source = stubSource();
const lib = await openLibrary(source); const lib = await openLibrary(source);
+3 -5
View File
@@ -165,15 +165,14 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
}, },
}; };
// Collection 2: "../Work" with no magic metadata. The server chose a name // Collection 2: "Work" with no magic metadata
// that tries to climb out of the backup directory.
const ck2 = sodium.crypto_secretbox_keygen(); const ck2 = sodium.crypto_secretbox_keygen();
const { ciphertext: encCK2, nonce: ck2N } = encryptSecretbox( const { ciphertext: encCK2, nonce: ck2N } = encryptSecretbox(
ck2, ck2,
masterKey, masterKey,
); );
const { ciphertext: encCN2, nonce: cn2N } = encryptSecretbox( const { ciphertext: encCN2, nonce: cn2N } = encryptSecretbox(
new TextEncoder().encode("../Work"), new TextEncoder().encode("Work"),
ck2, ck2,
); );
const rawColl2 = { const rawColl2 = {
@@ -497,8 +496,7 @@ describe("quak backup-metadata", () => {
await runBackup(outDir); await runBackup(outDir);
const collDirs = readdirSync(join(outDir, "collections")); const collDirs = readdirSync(join(outDir, "collections"));
// "../Work" is sanitized into one directory name. expect(collDirs.length).toBe(2);
expect(collDirs.sort()).toEqual(["10-Vacation", "20-__Work"]);
// Find the Vacation collection dir (prefixed with ID) // Find the Vacation collection dir (prefixed with ID)
const vacDir = collDirs.find((d) => d.includes("Vacation"))!; const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
-18
View File
@@ -64,24 +64,6 @@ describe("CLI file output (issue #52)", () => {
expect(thumbnailName(renamedFile)).toBe(`thumb_${RAW_TITLE}`); expect(thumbnailName(renamedFile)).toBe(`thumb_${RAW_TITLE}`);
}); });
it("sanitizes the title when naming `quak get` downloads", () => {
// Without `--out`, the server-supplied title names the file, so it must
// not be able to point outside the working directory.
const hostile = {
...renamedFile,
metadata: { ...renamedFile.metadata, title: "../../.bashrc" },
};
expect(originalName(hostile)).toBe("__.._.bashrc");
expect(thumbnailName(hostile)).toBe("thumb___.._.bashrc");
const untitled = {
...renamedFile,
metadata: { ...renamedFile.metadata, title: "" },
};
expect(originalName(untitled)).toBe("file-100");
expect(thumbnailName(untitled)).toBe("thumb_file-100");
});
it("does not use the editedName/editedTime projection", () => { it("does not use the editedName/editedTime projection", () => {
const record = deriveRecords([], [renamedFile]).photos.get(100); const record = deriveRecords([], [renamedFile]).photos.get(100);
// The projection prefers the edits and reports milliseconds; the CLI // The projection prefers the edits and reports milliseconds; the CLI
-197
View File
@@ -1,197 +0,0 @@
/**
* Tests for the client session lifecycle: `toJSON`, `fromJSON`, `logout`, and
* the CLI's `loadSession`, which reads the saved session file back into a
* client.
*/
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import sodium from "libsodium-wrappers-sumo";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { init, toBase64 } from "../../src/crypto/index.js";
import { Client, type ClientSnapshot } from "../../src/client.js";
import { loadSession } from "../../src/cli-session.js";
const validSnapshot = (): ClientSnapshot => {
const kp = sodium.crypto_box_keypair();
return {
email: "user@example.com",
userID: 42,
token: "test-token",
masterKey: toBase64(sodium.crypto_secretbox_keygen()),
secretKey: toBase64(kp.privateKey),
publicKey: toBase64(kp.publicKey),
};
};
// The client's key buffers are private; the tests read them to prove that
// logout wipes them.
const keyBuffers = (client: Client): Uint8Array[] => [
client["masterKey"],
client["secretKey"],
client["publicKey"],
];
beforeAll(async () => {
await init();
});
describe("Client.toJSON", () => {
it("round-trips through fromJSON unchanged", () => {
const snapshot = validSnapshot();
expect(Client.fromJSON(snapshot).toJSON()).toEqual(snapshot);
});
it("throws instead of emitting a snapshot without a token", () => {
const client = Client.fromJSON(validSnapshot());
client.getApiClient().clearAuthToken();
expect(() => client.toJSON()).toThrow(/no auth token/);
});
});
describe("Client.fromJSON", () => {
const shortKey = toBase64(new Uint8Array(16));
it.each([
["email", undefined],
["email", 7],
["email", ""],
["token", undefined],
["token", null],
["token", ""],
["userID", undefined],
["userID", "42"],
["userID", 4.2],
["masterKey", undefined],
["masterKey", 7],
["masterKey", "not base64!"],
["masterKey", shortKey],
["secretKey", undefined],
["secretKey", "not base64!"],
["secretKey", shortKey],
["publicKey", undefined],
["publicKey", "not base64!"],
["publicKey", shortKey],
])("rejects %s = %j, naming the field", (field, value) => {
const snapshot: Record<string, unknown> = { ...validSnapshot() };
snapshot[field] = value;
expect(() => Client.fromJSON(snapshot)).toThrow(
new RegExp(`^Invalid session data: ${field} `),
);
});
it.each([null, "a string", 42])("rejects a non-object %j", (value) => {
expect(() => Client.fromJSON(value)).toThrow(
/^Invalid session data: not a JSON object/,
);
});
});
describe("Client.logout", () => {
it("zeroes the key buffers and clears the token", () => {
const client = Client.fromJSON(validSnapshot());
const api = client.getApiClient();
const keys = keyBuffers(client);
client.logout();
for (const key of keys) {
expect(key.length).toBe(32);
expect(key.every((b) => b === 0)).toBe(true);
}
expect(api.getAuthToken()).toBeUndefined();
});
it("makes every later operation throw", async () => {
const client = Client.fromJSON(validSnapshot());
client.logout();
expect(() => client.whoami()).toThrow(/logged out/);
expect(() => client.toJSON()).toThrow(/logged out/);
expect(() => client.getApiClient()).toThrow(/logged out/);
expect(() => client.contentSource()).toThrow(/logged out/);
await expect(client.listCollections()).rejects.toThrow(/logged out/);
await expect(client.collectionsSince({ sinceTime: 0 })).rejects.toThrow(
/logged out/,
);
await expect(
client.filesSince({
collectionID: 1,
collectionKey: new Uint8Array(32),
sinceTime: 0,
}),
).rejects.toThrow(/logged out/);
await expect(
client.fetchMLData({ fileIDs: [1], fileKeys: new Map() }),
).rejects.toThrow(/logged out/);
});
it("stops a listing in flight from decrypting with the zeroed keys", async () => {
// The server answers only after the client has logged out. If the
// listing went on to decrypt this row with all-zero keys it would fail
// with a decryption error, not the logged-out one.
const row = {
id: 1,
owner: { id: 42 },
encryptedKey: toBase64(new Uint8Array(48)),
keyDecryptionNonce: toBase64(new Uint8Array(24)),
updationTime: 1,
};
const client: Client = Client.fromJSON(validSnapshot(), {
fetch: async () => {
client.logout();
return new Response(JSON.stringify({ collections: [row] }), {
status: 200,
headers: { "content-type": "application/json" },
});
},
});
await expect(client.listCollections()).rejects.toThrow(/logged out/);
});
});
describe("loadSession", () => {
let dir: string;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), "quak-session-test-"));
});
afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});
it("returns null when there is no session file", () => {
expect(loadSession(join(dir, "missing.json"))).toBeNull();
});
it("restores a client from a valid session file", () => {
const path = join(dir, "valid.json");
writeFileSync(path, JSON.stringify(validSnapshot()));
expect(loadSession(path)!.whoami()).toEqual({
email: "user@example.com",
userID: 42,
});
});
it("says the file is corrupt when it is not JSON", () => {
const path = join(dir, "truncated.json");
writeFileSync(path, '{"email": "user@exa');
expect(() => loadSession(path)).toThrow(
`Session file ${path} is corrupt`,
);
});
it("says the file is corrupt and names the bad field", () => {
const path = join(dir, "bad-key.json");
writeFileSync(
path,
JSON.stringify({ ...validSnapshot(), secretKey: "AAAA" }),
);
expect(() => loadSession(path)).toThrow(
new RegExp(`^Session file ${path} is corrupt: .*secretKey`),
);
});
});
+11 -78
View File
@@ -49,7 +49,6 @@
import { import {
existsSync, existsSync,
mkdirSync,
readdirSync, readdirSync,
readFileSync, readFileSync,
rmSync, rmSync,
@@ -502,22 +501,6 @@ const entryPoints = [
{ name: "downloadThumbnail", download: downloadThumbnail }, { name: "downloadThumbnail", download: downloadThumbnail },
]; ];
// With no `outPath`, the destination is named after `metadata.title`, relative
// to the working directory. Such tests run inside a temporary directory:
// `make check` must not create files in the repo root.
const inDirectory = async <T>(
dir: string,
run: () => Promise<T>,
): Promise<T> => {
const previous = process.cwd();
process.chdir(dir);
try {
return await run();
} finally {
process.chdir(previous);
}
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -553,59 +536,26 @@ describe("downloadFile", () => {
}); });
it("uses metadata.title as filename when outPath is omitted", async () => { it("uses metadata.title as filename when outPath is omitted", async () => {
// With no `outPath`, the destination is `metadata.title`, used
// verbatim as a path. The title here is therefore given inside the
// test's temporary directory: a bare relative name would resolve
// against the process working directory, i.e. the repo root, and
// `make check` must not create files in the repo — a failure between
// the write and any cleanup would leave one behind.
const plaintext = new Uint8Array([1, 2, 3]); const plaintext = new Uint8Array([1, 2, 3]);
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { header, ciphertext } = encryptFileBody(plaintext, key); const { header, ciphertext } = encryptFileBody(plaintext, key);
const thumbPush = const thumbPush =
sodium.crypto_secretstream_xchacha20poly1305_init_push(key); sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
const file = buildMockEnteFile(key, header, thumbPush.header); const file = buildMockEnteFile(key, header, thumbPush.header);
file.metadata.title = "fallback-name.png"; const titlePath = join(testDir, "fallback-name.png");
const dir = mkdtempSync(join(testDir, "title-")); file.metadata.title = titlePath;
const api = new ApiClient({ fetch: mockFetchForBody(ciphertext) }); const api = new ApiClient({ fetch: mockFetchForBody(ciphertext) });
const result = await inDirectory(dir, () => downloadFile(api, file)); const result = await downloadFile(api, file);
expect(result.path).toBe("fallback-name.png"); expect(result.path).toBe(titlePath);
expect(readFileSync(join(dir, "fallback-name.png"))).toEqual( expect(readFileSync(result.path)).toEqual(Buffer.from(plaintext));
Buffer.from(plaintext),
);
});
it("keeps a hostile title inside the working directory", async () => {
// The server controls the title. `../escaped.png` must not write to
// the parent directory; it becomes one file name in the current one.
const { api, file } = fixtureFor(
multiChunkKey,
multiChunk.header,
multiChunk.body,
);
file.metadata.title = "../escaped.png";
const parent = mkdtempSync(join(testDir, "hostile-"));
const dir = join(parent, "cwd");
mkdirSync(dir);
const result = await inDirectory(dir, () => downloadFile(api, file));
expect(result.path).toBe("__escaped.png");
expect(readdirSync(dir)).toEqual(["__escaped.png"]);
expect(readdirSync(parent)).toEqual(["cwd"]);
});
it("uses an explicit outPath verbatim, even one with ..", async () => {
// The caller is trusted: its path is not sanitized.
const { api, file } = fixtureFor(
multiChunkKey,
multiChunk.header,
multiChunk.body,
);
const dir = mkdtempSync(join(testDir, "explicit-"));
mkdirSync(join(dir, "sub"));
const outPath = join(dir, "sub", "..", "explicit.bin");
const result = await downloadFile(api, file, outPath);
expect(result.path).toBe(outPath);
expect(existsSync(join(dir, "explicit.bin"))).toBe(true);
}); });
it("handles a larger single-chunk file (random binary payload)", async () => { it("handles a larger single-chunk file (random binary payload)", async () => {
@@ -667,23 +617,6 @@ describe("downloadThumbnail", () => {
expect(result).toEqual({ path: outPath, bytesWritten: 4 }); expect(result).toEqual({ path: outPath, bytesWritten: 4 });
expect(readFileSync(outPath)).toEqual(Buffer.from(plaintext)); expect(readFileSync(outPath)).toEqual(Buffer.from(plaintext));
}); });
it("names the thumbnail thumb_ plus the sanitized title", async () => {
const { api, file } = fixtureFor(
multiChunkKey,
multiChunk.header,
multiChunk.body,
);
file.metadata.title = "/etc/passwd";
const dir = mkdtempSync(join(testDir, "thumb-title-"));
const result = await inDirectory(dir, () =>
downloadThumbnail(api, file),
);
expect(result.path).toBe("thumb__etc_passwd");
expect(readdirSync(dir)).toEqual(["thumb__etc_passwd"]);
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
-93
View File
@@ -1,93 +0,0 @@
// File names built from server-supplied metadata.
//
// quak does not trust the server. A file's title and a collection's name are
// decrypted from data the server hands us, and a hostile server (or a
// compromised account) can set them to anything. quak uses them to name files
// on disk: `quak get` without `--out`, `downloadFile` without `outPath`, the
// backup's symlink and collection directories, and the extension of every file
// in the originals cache. Each of those goes through `sanitizeFileName` or
// `safeExtension`, so a title can only ever name one file inside the directory
// the caller chose.
//
// A path the user supplies (`--out`, `outPath`) is never sanitized: the caller
// is trusted, the server is not.
import { describe, expect, it } from "vitest";
import { safeExtension, sanitizeFileName } from "../../src/filename.js";
const FALLBACK = "file-42";
describe("sanitizeFileName", () => {
it("passes a normal title through unchanged", () => {
expect(sanitizeFileName("IMG_0001.HEIC", FALLBACK)).toBe(
"IMG_0001.HEIC",
);
expect(sanitizeFileName("Holiday 2024 (1).jpg", FALLBACK)).toBe(
"Holiday 2024 (1).jpg",
);
expect(sanitizeFileName("café.jpg", FALLBACK)).toBe("café.jpg");
});
it("cannot climb out of the directory with ../", () => {
// Without sanitizing, this would overwrite the user's SSH keys.
expect(sanitizeFileName("../../.ssh/authorized_keys", FALLBACK)).toBe(
"__.._.ssh_authorized_keys",
);
expect(sanitizeFileName("..", FALLBACK)).toBe("_");
expect(sanitizeFileName("..\\..\\x", FALLBACK)).toBe("__.._x");
});
it("cannot name an absolute path", () => {
expect(sanitizeFileName("/etc/passwd", FALLBACK)).toBe("_etc_passwd");
expect(sanitizeFileName("C:\\Windows\\x.dll", FALLBACK)).toBe(
"C__Windows_x.dll",
);
});
it("replaces embedded separators, so the name stays one file", () => {
expect(sanitizeFileName("a/b\\c.jpg", FALLBACK)).toBe("a_b_c.jpg");
});
it("replaces NUL and other control characters", () => {
// A NUL truncates the path in C code and makes Node's fs throw.
expect(sanitizeFileName("evil\0.jpg", FALLBACK)).toBe("evil_.jpg");
expect(sanitizeFileName("line\nbreak\x7f.jpg", FALLBACK)).toBe(
"line_break_.jpg",
);
});
it("does not produce a hidden file", () => {
expect(sanitizeFileName(".bashrc", FALLBACK)).toBe("_bashrc");
});
it("does not produce a Windows device name", () => {
expect(sanitizeFileName("CON", FALLBACK)).toBe("_CON");
expect(sanitizeFileName("nul.txt", FALLBACK)).toBe("_nul.txt");
expect(sanitizeFileName("LPT1", FALLBACK)).toBe("_LPT1");
// Only the exact names are reserved.
expect(sanitizeFileName("console.jpg", FALLBACK)).toBe("console.jpg");
});
it("falls back to the given name for an empty title", () => {
expect(sanitizeFileName("", FALLBACK)).toBe(FALLBACK);
});
});
describe("safeExtension", () => {
it("keeps a normal extension", () => {
expect(safeExtension("IMG_0001.HEIC")).toBe(".HEIC");
expect(safeExtension("clip.mp4")).toBe(".mp4");
});
it("uses .bin when there is no extension", () => {
expect(safeExtension("")).toBe(".bin");
expect(safeExtension("README")).toBe(".bin");
});
it("uses .bin when the extension holds anything but letters and digits", () => {
expect(safeExtension("x.j\\..\\pg")).toBe(".bin");
expect(safeExtension("x.jp g")).toBe(".bin");
expect(safeExtension("x.jpg\0")).toBe(".bin");
});
});
-19
View File
@@ -226,25 +226,6 @@ describe("ContentCache.original / thumbnail", () => {
expect(skips).toEqual(["skipped"]); expect(skips).toEqual(["skipped"]);
}); });
it("takes only a letters-and-digits extension from the title", async () => {
// The title comes from the server; an extension such as `.\..\x`
// must not reach the cache file name, so it becomes `.bin`.
const { cache } = buildCache({
files: [file(1, "a.jpg"), file(2, "b.\\..\\x"), file(3, "")],
});
await cache.open();
expect((await cache.original(1)).path).toBe(
join(cacheDir, "originals", "1.jpg"),
);
expect((await cache.original(2)).path).toBe(
join(cacheDir, "originals", "2.bin"),
);
expect((await cache.original(3)).path).toBe(
join(cacheDir, "originals", "3.bin"),
);
});
it("serves a file already present in the download directory without fetching", async () => { it("serves a file already present in the download directory without fetching", async () => {
const downloadDirectory = join(root, "backup"); const downloadDirectory = join(root, "backup");
mkdirSync(join(downloadDirectory, "originals"), { recursive: true }); mkdirSync(join(downloadDirectory, "originals"), { recursive: true });
+3 -33
View File
@@ -146,13 +146,10 @@ const buildSharedRawCollection = (
const buildRawFile = ( const buildRawFile = (
collectionKey: Uint8Array, collectionKey: Uint8Array,
opts?: { opts?: {
// Any JSON value; `undefined` leaves the title out of the metadata. title?: string;
title?: unknown;
fileType?: number; fileType?: number;
creationTime?: number; creationTime?: number;
info?: { fileSize?: number; thumbSize?: number }; info?: { fileSize?: number; thumbSize?: number };
// Replaces the whole metadata JSON value.
metadata?: unknown;
}, },
): RawEnteFile => { ): RawEnteFile => {
const fileKey = sodium.crypto_secretbox_keygen(); const fileKey = sodium.crypto_secretbox_keygen();
@@ -161,8 +158,8 @@ const buildRawFile = (
collectionKey, collectionKey,
); );
const defaultMetadata = { const metadata = {
title: opts && "title" in opts ? opts.title : "IMG_0001.jpg", title: opts?.title ?? "IMG_0001.jpg",
fileType: opts?.fileType ?? 0, fileType: opts?.fileType ?? 0,
creationTime: opts?.creationTime ?? 1700000000000000, creationTime: opts?.creationTime ?? 1700000000000000,
modificationTime: 1700000000000000, modificationTime: 1700000000000000,
@@ -170,8 +167,6 @@ const buildRawFile = (
longitude: 2.3522, longitude: 2.3522,
hash: "abcdef1234567890", hash: "abcdef1234567890",
}; };
const metadata =
opts && "metadata" in opts ? opts.metadata : defaultMetadata;
// File metadata is encrypted as a single-chunk secretstream blob // File metadata is encrypted as a single-chunk secretstream blob
// (not secretbox). The decryptionHeader is the secretstream init header. // (not secretbox). The decryptionHeader is the secretstream init header.
const metadataBytes = new TextEncoder().encode(JSON.stringify(metadata)); const metadataBytes = new TextEncoder().encode(JSON.stringify(metadata));
@@ -326,31 +321,6 @@ describe("model.decryptFile", () => {
expect(file.metadata.longitude).toBeCloseTo(2.3522); expect(file.metadata.longitude).toBeCloseTo(2.3522);
}); });
it("reads a missing or non-string title as an empty string", () => {
// The server controls the metadata JSON. A title that is not a
// string must not reach code that builds file names from it.
const masterKey = sodium.crypto_secretbox_keygen();
const { collectionKey } = buildRawCollection(masterKey);
for (const title of [undefined, null, 42, ["a"], { x: "../y" }]) {
const file = decryptFile(
buildRawFile(collectionKey, { title }),
collectionKey,
);
expect(file.metadata.title).toBe("");
}
});
it("rejects metadata that is not a JSON object", () => {
const masterKey = sodium.crypto_secretbox_keygen();
const { collectionKey } = buildRawCollection(masterKey);
for (const metadata of [null, "IMG_0001.jpg", 7, []]) {
const raw = buildRawFile(collectionKey, { metadata });
expect(() => decryptFile(raw, collectionKey)).toThrow(
"file 200: metadata is not a JSON object",
);
}
});
it("maps fileType numbers to FileType strings", () => { it("maps fileType numbers to FileType strings", () => {
// Ente uses: 0=image, 1=video, 2=livePhoto // Ente uses: 0=image, 1=video, 2=livePhoto
const masterKey = sodium.crypto_secretbox_keygen(); const masterKey = sodium.crypto_secretbox_keygen();
-19
View File
@@ -385,25 +385,6 @@ describe("isSafeToReplay", () => {
expect(isRetryable(mixed)).toBe(true); expect(isRetryable(mixed)).toBe(true);
expect(isSafeToReplay(mixed)).toBe(false); expect(isSafeToReplay(mixed)).toBe(false);
}); });
it("does not replay a chain longer than the walk reads", () => {
// Eight connect errnos, then a reset at the ninth link, below the
// limit. The walk never sees the reset, so it cannot rule it out.
const refusedChain = (below: Error | undefined): Error => {
let err = below;
for (let i = 0; i < 8; i++) {
err = Object.assign(new Error(`refused ${i}`, { cause: err }), {
code: "ECONNREFUSED",
});
}
return err as Error;
};
expect(isSafeToReplay(refusedChain(errnoError("ECONNRESET")))).toBe(
false,
);
// The same eight links with nothing below them are replayable.
expect(isSafeToReplay(refusedChain(undefined))).toBe(true);
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+8 -1
View File
@@ -528,6 +528,13 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
"@types/libsodium-wrappers-sumo@0.8.2":
version "0.8.2"
resolved "https://registry.yarnpkg.com/@types/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.2.tgz#488e8747fbb982fe901020b5afeaddfa63da6830"
integrity sha512-uFOBpg/r21hExVlh2ty8YpDfSR+Yy3Jn8XS4+SSjitbhTxdYq+pBz/49XRxyUFe8SzqujHf/Wu0/O4d+FUtNfQ==
dependencies:
libsodium-wrappers-sumo "*"
"@types/node@22.18.13": "@types/node@22.18.13":
version "22.18.13" version "22.18.13"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.18.13.tgz#a037c4f474b860be660e05dbe92a9ef945472e28" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.18.13.tgz#a037c4f474b860be660e05dbe92a9ef945472e28"
@@ -1242,7 +1249,7 @@ libsodium-sumo@^0.8.0:
resolved "https://registry.yarnpkg.com/libsodium-sumo/-/libsodium-sumo-0.8.4.tgz#6d4687781fa0ad398af14a7df872d5c27cf8cd31" resolved "https://registry.yarnpkg.com/libsodium-sumo/-/libsodium-sumo-0.8.4.tgz#6d4687781fa0ad398af14a7df872d5c27cf8cd31"
integrity sha512-TMtHShQfVVsaxDygyapvUC3o7YsPgXa/hRWeIgzyFz6w5k/1hirGptCxp1U7XwW3rCskaTTYKgV10v86UiGgNw== integrity sha512-TMtHShQfVVsaxDygyapvUC3o7YsPgXa/hRWeIgzyFz6w5k/1hirGptCxp1U7XwW3rCskaTTYKgV10v86UiGgNw==
libsodium-wrappers-sumo@0.8.4: libsodium-wrappers-sumo@*, libsodium-wrappers-sumo@0.8.4:
version "0.8.4" version "0.8.4"
resolved "https://registry.yarnpkg.com/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.4.tgz#6656a3e7e0551ecce08ddee4bfb501a092eac6fa" resolved "https://registry.yarnpkg.com/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.4.tgz#6656a3e7e0551ecce08ddee4bfb501a092eac6fa"
integrity sha512-ql7hcgulKZ3ekfa2DGAogcCKsWU0diA/0nArz1CFzh93WQdb46/Kj18ka/Hifq6uA3Ush34Pc6vU/6HXeRwUkg== integrity sha512-ql7hcgulKZ3ekfa2DGAogcCKsWU0diA/0nArz1CFzh93WQdb46/Kj18ka/Hifq6uA3Ush34Pc6vU/6HXeRwUkg==