6 Commits
Author SHA1 Message Date
sneak 435c84fc35 Harden the retry classifier and pin per-attempt deadlines (closes #80)
check / check (push) Successful in 17s
A POST or PUT is replayed only when every errno in the cause chain is a
connect errno and the walk reached the end of the chain, 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 chain deeper than the 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-23 00:34:34 +00:00
clawbot 52f58f5d2b Harden the JPEG EXIF scan against malformed input (closes #11)
check / check (push) Successful in 29s
The segment scan behind `backup-metadata --exif` now checks every
segment length against the bytes that remain and stops on lengths
under 2, so a truncated or corrupt original can neither throw nor loop.
A malformed or unparseable EXIF segment is recorded as
`imageMetadata.exifError`, and a failure to read the original as
`imageMetadataError` in the per-file JSON, instead of the field being
silently left out. Tests use short hand-built byte arrays.

Model: opus-5-5
2026-09-23 02:28:15 +02:00
clawbot b44c4ba6d7 Keep make test from collecting tests in nested checkouts (closes #25)
check / check (push) Successful in 33s
vitest does not read .gitignore when finding tests, so a checkout nested
under .claude/ had its whole test/ tree run as part of this suite.
vitest.config.ts adds .claude/** to vitest's default excludes. The new
packaging test plants a nested checkout in a temp directory and fails if
vitest, run with this config, would collect it.

Model: opus-5-5
2026-09-23 02:18:31 +02:00
clawbot d50b296d3a Drop the deprecated @types/libsodium-wrappers-sumo stub (closes #27)
check / check (push) Successful in 46s
The package is an empty stub with no declarations; libsodium-wrappers-sumo
ships its own types. Removed with yarn remove, which regenerated yarn.lock.

Model: opus-5-5
2026-09-23 02:15:08 +02:00
clawbot b7d6ab99f4 Validate session snapshots and wipe keys on logout (closes #10)
check / check (push) Successful in 41s
Client.fromJSON checks every snapshot field and each key's decoded length
and throws an error naming the bad field. toJSON reads the token through a
new ApiClient.getAuthToken and throws when there is none. logout zeroes the
key buffers in place; collectionsSince re-checks for logout after its
request so it never decrypts with zeroed keys. The CLI now reports a
corrupt session file separately from a missing one.

Model: opus-5-5
2026-09-23 02:08:02 +02:00
clawbot 3871d6228e Sanitize file names taken from server metadata (closes #9)
check / check (push) Successful in 37s
A file title or album name decrypted from server data could name a path
outside the chosen directory (`../../.ssh/authorized_keys`). One module,
src/filename.ts, now makes such names safe for `quak get`/`get-thumb`
without `--out`, downloadFile/downloadThumbnail without outPath, and the
backup and metadata backup trees. Originals-cache extensions are limited to
letters and digits. A user-supplied path is still used as is. decryptFile
reads a missing or non-string title as "" and rejects metadata that is not
a JSON object.

Model: opus-5-5
2026-09-23 02:04:31 +02:00
29 changed files with 1231 additions and 198 deletions
+25 -16
View File
@@ -323,6 +323,7 @@ Endpoints used:
- `GET /collections/v2/diff?collectionID=<id>&sinceTime=<usec>`: list files in a - `GET /collections/v2/diff?collectionID=<id>&sinceTime=<usec>`: list files in a
collection; paginate while `hasMore` is true. collection; paginate while `hasMore` is true.
- `GET https://files.ente.io/?fileID=<id>`: download encrypted file bytes. - `GET https://files.ente.io/?fileID=<id>`: download encrypted file bytes.
- `POST /files/data/fetch`: fetch encrypted ML data for a batch of files.
- `POST /files/upload-url`: mint a presigned upload URL (for thumbnail repair). - `POST /files/upload-url`: mint a presigned upload URL (for thumbnail repair).
- `PUT /files/thumbnail`: register an uploaded thumbnail's object key. - `PUT /files/thumbnail`: register an uploaded thumbnail's object key.
@@ -377,20 +378,23 @@ headers — `getFileStream` returns as soon as headers arrive, so a deadline tha
only guarded the initial request would leave the same hang one layer down. only guarded the initial request would leave the same hang one layer down.
**Non-idempotent requests are not blindly replayed.** `postJSON` and `putJSON` **Non-idempotent requests are not blindly replayed.** `postJSON` and `putJSON`
reach `/users/srp/create-session`, `/users/two-factor/verify` — which consumes send every `POST` and `PUT` in the endpoint list above; some of them change
one of a small number of second-factor attempts — and `/files/thumbnail`. They server state, and `/users/two-factor/verify` consumes one of a small number of
are retried only on the three failures that establish no TCP connection to the second-factor attempts. They are retried only when every errno in the error's
server ever existed, so no request byte can have been transmitted: `ENOTFOUND` `cause` chain is one of the three that establish no TCP connection to the server
and `EAI_AGAIN` (name resolution produced no address) and `ECONNREFUSED` (the ever existed, so no request byte can have been transmitted: `ENOTFOUND` and
peer refused the connection). A 5xx, a mid-flight reset and a deadline are all `EAI_AGAIN` (name resolution produced no address) and `ECONNREFUSED` (the peer
left to the caller, because each of them can happen after the server has already refused the connection). A 5xx, a mid-flight reset and a deadline are all left
acted. The routing errnos `EHOSTUNREACH`, `ENETUNREACH` and `ENETDOWN` are to the caller, because each of them can happen after the server has already
excluded for the same reason, despite looking like connect-time failures: on acted. These two do not follow redirects either: a redirect means the server
Linux an ICMP unreachable arriving mid-flight, or a local interface going down already received the request, so it is reported as an error and not retried. The
after the request was written, delivers them on an already-established socket. routing errnos `EHOSTUNREACH`, `ENETUNREACH` and `ENETDOWN` are excluded for the
They stay retryable for the idempotent calls. `putFile` is exempt: a presigned same reason, despite looking like connect-time failures: on Linux an ICMP
PUT stores one whole object at one key in one request, so replaying it has no unreachable arriving mid-flight, or a local interface going down after the
partial state to damage. request was written, delivers them on an already-established socket. They stay
retryable for the idempotent calls. `putFile` is exempt: a presigned PUT stores
one whole object at one key in one request, so replaying it has no partial state
to damage.
A download is retried as a whole — request, stream consumption, and decryption — A download is retried as a whole — request, stream consumption, and decryption —
because a socket reset after the response headers have arrived surfaces in the because a socket reset after the response headers have arrived surfaces in the
@@ -422,13 +426,18 @@ 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. working client from that snapshot without re-authenticating; it checks every
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. you would treat the password itself. A missing file is reported as "not logged
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
+38
View File
@@ -18,6 +18,44 @@ Tag v1.0.0.
# Completed Steps # Completed Steps
- 2026-09-23: Hardened the JPEG EXIF scan behind `backup-metadata --exif` (issue
11). Every segment length is checked against the remaining bytes and lengths
under 2 stop the scan, so a truncated or corrupt original can neither throw
nor loop. A malformed or unparseable EXIF segment is recorded as
`imageMetadata.exifError`, and a failure to read the original as
`imageMetadataError` in the per-file JSON, instead of the field being left
out.
- 2026-09-22: Hardened the retry classifier (issue 80). A `POST` or `PUT` is
replayed only when every errno in the cause chain is a connect errno, and it
no longer follows redirects. `getRetryOptions()` returns a copy. Tests pin
every errno the classifier names, the cause-chain depth limit, cycle
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
rule covers.
- 2026-09-22: Stopped `make test` collecting tests from checkouts nested under
`.claude/` (issue 25). vitest ignores `.gitignore` when finding tests, so a
nested checkout ran the whole suite again; `vitest.config.ts` now adds
`.claude/**` to vitest's default excludes, and
`test/packaging/nested-checkout.test.ts` plants a nested checkout in a temp
directory and fails if vitest would collect it.
- 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()`
+16 -20
View File
@@ -2,13 +2,7 @@
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 { import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
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";
@@ -22,6 +16,7 @@ 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,
@@ -31,15 +26,6 @@ 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), {
@@ -48,14 +34,23 @@ const saveSession = (snapshot: ClientSnapshot): void => {
}; };
const requireSession = (): Client => { const requireSession = (): Client => {
const snapshot = loadSession(); let client: Client | null;
if (!snapshot) { try {
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.fromJSON(snapshot); return client;
}; };
const prompt = async (message: string): Promise<string> => input({ message }); const prompt = async (message: string): Promise<string> => input({ message });
@@ -157,7 +152,8 @@ program
program program
.command("whoami") .command("whoami")
.description("Print the logged-in account") .description("Print the logged-in account")
.action(() => { .action(async () => {
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,7 +29,6 @@
}, },
"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",
+18 -13
View File
@@ -139,11 +139,16 @@ 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.
// A copy, so the caller cannot change this client's settings through it.
getRetryOptions(): ResolvedRetryOptions { getRetryOptions(): ResolvedRetryOptions {
return this.retry; return { ...this.retry };
} }
private headers(extra?: Record<string, string>): Record<string, string> { private headers(extra?: Record<string, string>): Record<string, string> {
@@ -224,15 +229,15 @@ export class ApiClient {
async postJSON<T>(path: string, body: unknown): Promise<T> { async postJSON<T>(path: string, body: unknown): Promise<T> {
const url = `${this.apiOrigin}${path}`; const url = `${this.apiOrigin}${path}`;
// Idempotency: this reaches `/users/srp/create-session`, // Not idempotent: a POST is replayed only when `isSafeToReplay`
// `/users/two-factor/verify` and `/users/ott`, all of which change // says no request byte can have reached the server. The endpoints
// server state — verifying a second factor consumes one of a small // this covers are listed in the README under "Endpoints used".
// number of attempts. So a POST is replayed only on a failure that //
// establishes no TCP connection to the server ever existed: DNS // Redirects are not followed. The origin has already received the
// produced no address, or the peer refused the connection. A 5xx, a // request when it answers with one, so a connection refused by the
// mid-flight reset, a routing errno (which Linux also delivers on an // redirect target would look replay-safe when it is not. The API has
// established socket) and a timeout are all left to the caller, // no legitimate redirect, so one surfaces as an `ApiError` with its
// because each of them can occur after the server has already acted. // 3xx status, which is not retried.
return withRetry( return withRetry(
async () => { async () => {
const resp = await this._fetch(url, { const resp = await this._fetch(url, {
@@ -241,6 +246,7 @@ export class ApiClient {
"Content-Type": "application/json", "Content-Type": "application/json",
}), }),
body: JSON.stringify(body), body: JSON.stringify(body),
redirect: "manual",
signal: AbortSignal.timeout(this.requestTimeoutMs), signal: AbortSignal.timeout(this.requestTimeoutMs),
}); });
await this.throwIfError(resp); await this.throwIfError(resp);
@@ -299,9 +305,7 @@ export class ApiClient {
async putJSON<T>(path: string, body: unknown): Promise<T> { async putJSON<T>(path: string, body: unknown): Promise<T> {
const url = `${this.apiOrigin}${path}`; const url = `${this.apiOrigin}${path}`;
// Same idempotency rule as `postJSON`, for the same reason: this // Same replay and redirect rules as `postJSON`, for the same reasons.
// reaches `/files/thumbnail`, which registers an uploaded thumbnail
// against a file.
return withRetry( return withRetry(
async () => { async () => {
const resp = await this._fetch(url, { const resp = await this._fetch(url, {
@@ -310,6 +314,7 @@ export class ApiClient {
"Content-Type": "application/json", "Content-Type": "application/json",
}), }),
body: JSON.stringify(body), body: JSON.stringify(body),
redirect: "manual",
signal: AbortSignal.timeout(this.requestTimeoutMs), signal: AbortSignal.timeout(this.requestTimeoutMs),
}); });
await this.throwIfError(resp); await this.throwIfError(resp);
+8 -11
View File
@@ -40,8 +40,9 @@ import {
symlinkSync, symlinkSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { basename, dirname, extname, join, relative } from "node:path"; import { basename, dirname, 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;
@@ -108,16 +109,11 @@ 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 =>
const ext = extname(file.metadata.title || "") || ".bin"; `${file.id}${safeExtension(file.metadata.title)}`;
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.
@@ -370,7 +366,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 = sanitizePath(c.name || `collection-${c.id}`); const colDirName = sanitizeFileName(c.name, `collection-${c.id}`);
const colDir = join(collectionsDir, colDirName); const colDir = join(collectionsDir, colDirName);
mkdirSync(colDir, { recursive: true }); mkdirSync(colDir, { recursive: true });
@@ -381,8 +377,9 @@ 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 = sanitizePath( const linkName = sanitizeFileName(
file.metadata.title || `file-${file.id}`, file.metadata.title,
`file-${file.id}`,
); );
const linkPath = join(colDir, linkName); const linkPath = join(colDir, linkName);
try { try {
+6 -3
View File
@@ -9,6 +9,7 @@
// `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`.
@@ -32,9 +33,11 @@ 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. // Default output path for `quak get` when `--out` is not given. The title comes
export const originalName = (file: EnteFile): string => file.metadata.title; // from the server, so it is sanitized; `--out` is the user's and is used as is.
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_${file.metadata.title}`; `thumb_${originalName(file)}`;
+26
View File
@@ -0,0 +1,26 @@
// 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}`);
}
};
+62 -11
View File
@@ -125,18 +125,57 @@ export class Client {
); );
} }
static fromJSON( // Restore a client from a `toJSON()` snapshot. The snapshot usually comes
snapshot: ClientSnapshot, // straight from `JSON.parse` of a file on disk, so every field is checked
apiOptions?: ApiClientOptions, // before use; a bad one throws an error naming it. Needs `init()` first.
): Client { static fromJSON(snapshot: unknown, apiOptions?: ApiClientOptions): Client {
const api = new ApiClient({ ...apiOptions, authToken: snapshot.token }); const invalid = (field: string, problem: string): Error =>
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,
snapshot.email, s.email as string,
snapshot.userID, s.userID as number,
fromBase64(snapshot.masterKey), key("masterKey"),
fromBase64(snapshot.secretKey), key("secretKey"),
fromBase64(snapshot.publicKey), key("publicKey"),
); );
} }
@@ -164,19 +203,29 @@ 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: this.api["token"]!, 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
@@ -192,6 +241,8 @@ 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[] = [];
+8 -2
View File
@@ -11,6 +11,7 @@ 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";
@@ -286,7 +287,10 @@ export const downloadFile = async (
outPath?: string, outPath?: string,
onProgress?: ProgressCallback, onProgress?: ProgressCallback,
): Promise<DownloadResult> => { ): Promise<DownloadResult> => {
const resolvedPath = outPath ?? file.metadata.title; // `outPath` is the caller's and is used as is; the title is the server's
// 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,
@@ -305,7 +309,9 @@ export const downloadThumbnail = async (
outPath?: string, outPath?: string,
onProgress?: ProgressCallback, onProgress?: ProgressCallback,
): Promise<DownloadResult> => { ): Promise<DownloadResult> => {
const resolvedPath = outPath ?? `thumb_${file.metadata.title}`; const resolvedPath =
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
@@ -0,0 +1,37 @@
// 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";
};
+3 -4
View File
@@ -40,6 +40,7 @@ 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";
@@ -209,10 +210,8 @@ class AbortDrop extends Error {
} }
} }
const originalName = (file: EnteFile): string => { const originalName = (file: EnteFile): string =>
const ext = extname(file.metadata.title || "") || ".bin"; `${file.id}${safeExtension(file.metadata.title)}`;
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>`).
+91 -70
View File
@@ -4,6 +4,7 @@ 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";
@@ -14,21 +15,35 @@ export interface MetadataBackupOptions {
onProgress?: ProgressCallback; onProgress?: ProgressCallback;
} }
const sanitizePath = (name: string): string => // Find the raw EXIF APP1 segment in JPEG bytes. Returns `exif` (the segment
name.replace(/[/\\:*?"<>|]/g, "_").replace(/^\.+/, "_"); // data, starting at the "Exif\0\0" header) when there is one, nothing when the
// bytes are not a JPEG or carry no EXIF, and `error` when the segment layout is
// Extract the raw EXIF APP1 segment from JPEG bytes. Returns the EXIF // malformed. Each segment length is checked against the bytes that remain and
// data buffer (starting after the APP1 length field, at the "Exif\0\0" // each step moves forward by at least 4 bytes, so the scan ends on any input.
// header) or undefined if no APP1 marker is found. export const extractExifFromJpeg = (
const extractExifFromJpeg = (buf: Uint8Array): Buffer | undefined => { buf: Uint8Array,
if (buf[0] !== 0xff || buf[1] !== 0xd8) return undefined; ): { exif?: Buffer; error?: string } => {
if (buf[0] !== 0xff || buf[1] !== 0xd8) return {};
let offset = 2; let offset = 2;
while (offset < buf.length - 1) { while (offset < buf.length) {
if (buf[offset] !== 0xff) return undefined; if (offset + 2 > buf.length)
return { error: `truncated segment marker at byte ${offset}` };
if (buf[offset] !== 0xff)
return { error: `no segment marker at byte ${offset}` };
const marker = buf[offset + 1]!; const marker = buf[offset + 1]!;
if (marker === 0xda) break; // start of scan, no more markers if (marker === 0xda) return {}; // start of scan, no more markers
if (offset + 3 >= buf.length) break; if (offset + 4 > buf.length)
return { error: `truncated segment length at byte ${offset}` };
const len = (buf[offset + 2]! << 8) | buf[offset + 3]!; const len = (buf[offset + 2]! << 8) | buf[offset + 3]!;
// The length counts its own two bytes, so anything under 2 is invalid.
if (len < 2)
return {
error: `segment length ${len} at byte ${offset} is too small`,
};
if (offset + 2 + len > buf.length)
return {
error: `segment length ${len} at byte ${offset} runs past the end of the file`,
};
if (marker === 0xe1) { if (marker === 0xe1) {
// APP1 — check for "Exif\0\0" header // APP1 — check for "Exif\0\0" header
if ( if (
@@ -37,65 +52,70 @@ const extractExifFromJpeg = (buf: Uint8Array): Buffer | undefined => {
buf[offset + 6] === 0x69 && buf[offset + 6] === 0x69 &&
buf[offset + 7] === 0x66 buf[offset + 7] === 0x66
) { ) {
return Buffer.from( return {
buf.buffer, exif: Buffer.from(
buf.byteOffset + offset + 4, buf.buffer,
len - 2, buf.byteOffset + offset + 4,
); len - 2,
),
};
} }
} }
offset += 2 + len; offset += 2 + len;
} }
return undefined; return { error: "file ends before the image data" };
}; };
const extractImageMetadata = ( // Extract dimensions, EXIF and XMP from a file's bytes. When the EXIF segment
// is malformed or cannot be parsed, the record carries the reason in
// `exifError`.
export const extractImageMetadata = (
fileBytes: Uint8Array, fileBytes: Uint8Array,
): Record<string, unknown> | undefined => { ): Record<string, unknown> | undefined => {
const result: Record<string, unknown> = {};
// Try to get dimensions from JPEG decode
try { try {
const result: Record<string, unknown> = {}; const decoded = jpeg.decode(fileBytes, {
useTArray: true,
// Try to get dimensions from JPEG decode formatAsRGBA: false,
try { });
const decoded = jpeg.decode(fileBytes, { result.format = "jpeg";
useTArray: true, result.width = decoded.width;
formatAsRGBA: false, result.height = decoded.height;
});
result.format = "jpeg";
result.width = decoded.width;
result.height = decoded.height;
} catch {
// Not a JPEG or corrupt; still try EXIF extraction
}
const exifBuf = extractExifFromJpeg(fileBytes);
if (exifBuf) {
try {
result.exif = exifReader(exifBuf);
} catch {
result.exifRaw = exifBuf.toString("base64");
}
}
// Extract XMP (look for "http://ns.adobe.com/xap" in the bytes)
const xmpStart = Buffer.from(fileBytes).indexOf("<?xpacket begin");
if (xmpStart !== -1) {
const xmpEnd = Buffer.from(fileBytes).indexOf(
"<?xpacket end",
xmpStart,
);
if (xmpEnd !== -1) {
const end = Buffer.from(fileBytes).indexOf("?>", xmpEnd);
result.xmp = Buffer.from(fileBytes)
.subarray(xmpStart, end !== -1 ? end + 2 : xmpEnd + 50)
.toString("utf-8");
}
}
return Object.keys(result).length > 0 ? result : undefined;
} catch { } catch {
return undefined; // Not every original is a JPEG (PNG, HEIC, video), so a failed decode
// is expected and only means no dimensions; a malformed JPEG is still
// reported below through `exifError`.
} }
const { exif, error } = extractExifFromJpeg(fileBytes);
if (error) result.exifError = error;
if (exif) {
try {
result.exif = exifReader(exif);
} catch (err) {
result.exifRaw = exif.toString("base64");
result.exifError = err instanceof Error ? err.message : String(err);
}
}
// Extract XMP (look for "http://ns.adobe.com/xap" in the bytes)
const xmpStart = Buffer.from(fileBytes).indexOf("<?xpacket begin");
if (xmpStart !== -1) {
const xmpEnd = Buffer.from(fileBytes).indexOf(
"<?xpacket end",
xmpStart,
);
if (xmpEnd !== -1) {
const end = Buffer.from(fileBytes).indexOf("?>", xmpEnd);
result.xmp = Buffer.from(fileBytes)
.subarray(xmpStart, end !== -1 ? end + 2 : xmpEnd + 50)
.toString("utf-8");
}
}
return Object.keys(result).length > 0 ? result : undefined;
}; };
// Read a file's original bytes through the library's content cache and extract // Read a file's original bytes through the library's content cache and extract
@@ -105,13 +125,9 @@ const extractImageMetadata = (
const extractExif = async ( const extractExif = async (
photo: Photo, photo: Photo,
): Promise<Record<string, unknown> | undefined> => { ): Promise<Record<string, unknown> | undefined> => {
try { const { path } = await photo.original();
const { path } = await photo.original(); const fileBytes = new Uint8Array(readFileSync(path));
const fileBytes = new Uint8Array(readFileSync(path)); return extractImageMetadata(fileBytes);
return extractImageMetadata(fileBytes);
} catch {
return undefined;
}
}; };
// Dump every decrypted metadata layer the account holds into a directory tree // Dump every decrypted metadata layer the account holds into a directory tree
@@ -151,7 +167,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}-${sanitizePath(col.name || "unnamed")}`; const dirName = `${col.id}-${sanitizeFileName(col.name, "unnamed")}`;
const colDir = join(outDir, "collections", dirName); const colDir = join(outDir, "collections", dirName);
mkdirSync(colDir, { recursive: true }); mkdirSync(colDir, { recursive: true });
@@ -217,8 +233,13 @@ export const runMetadataBackup = async (
if (wantExif && !writtenFileIDs.has(file.id)) { if (wantExif && !writtenFileIDs.has(file.id)) {
log(`[${file.metadata.title}] Extracting EXIF...`); log(`[${file.metadata.title}] Extracting EXIF...`);
const exifData = await extractExif(photo); try {
if (exifData) fileMeta.imageMetadata = exifData; const exifData = await extractExif(photo);
if (exifData) fileMeta.imageMetadata = exifData;
} catch (err) {
fileMeta.imageMetadataError =
err instanceof Error ? err.message : String(err);
}
} }
writtenFileIDs.add(file.id); writtenFileIDs.add(file.id);
+10 -1
View File
@@ -98,9 +98,18 @@ 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 = {
title: metadataJSON.title ?? "", // The server controls this JSON: a title that is missing or not a
// 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,
+25 -12
View File
@@ -88,18 +88,21 @@ 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. // connection permanent. `complete` is false when the walk stopped at the
const causeCodes = (err: unknown): string[] => { // depth limit with more of the chain still below it.
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") break; if (current === null || typeof current !== "object") {
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) break; if (cause === current) return { codes, complete: true };
current = cause; current = cause;
} }
return codes; return { codes, complete: current === null || typeof current !== "object" };
}; };
const isAbort = (err: unknown): boolean => { const isAbort = (err: unknown): boolean => {
@@ -145,15 +148,15 @@ 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).some((code) => TRANSPORT_CODES.has(code)); return causeCodes(err).codes.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?
// //
// `isRetryable` is the wrong question for a request that changes state. // `isRetryable` is the wrong question for a request that changes state.
// quak's non-idempotent calls are `/users/srp/create-session`, // `postJSON` and `putJSON` use this for every `POST` and `PUT` listed in the
// `/users/two-factor/verify` — which consumes one of a small number of 2FA // README under "Endpoints used"; verifying a second factor, for one, consumes
// attempts — and `/files/thumbnail`. They are replayed only on the failures in // one of a small number of attempts. They are replayed only on the failures in
// `CONNECT_CODES`, which establish that no TCP connection to the server ever // `CONNECT_CODES`, which establish that no TCP connection to the server ever
// existed: there was no address to connect to, or the peer refused the // existed: there was no address to connect to, or the peer refused the
// connection outright. A request byte cannot have been transmitted, so the // connection outright. A request byte cannot have been transmitted, so the
@@ -162,9 +165,19 @@ export const isRetryable = (err: unknown): boolean => {
// Everything else is ambiguous. A 5xx proves the server did process the // Everything else is ambiguous. A 5xx proves the server did process the
// request. A reset or a broken pipe can arrive after it was fully sent and // request. A reset or a broken pipe can arrive after it was fully sent and
// 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. // deadline says nothing at all about the server's state. So every errno in the
export const isSafeToReplay = (err: unknown): boolean => // cause chain must be a connect errno: one other errno anywhere in the chain
isRetryable(err) && causeCodes(err).some((code) => CONNECT_CODES.has(code)); // is doubt, and doubt is not replayed. A chain longer than the walk is doubt
// too: the links below the limit were never read.
export const isSafeToReplay = (err: unknown): boolean => {
const { codes, complete } = causeCodes(err);
return (
isRetryable(err) &&
complete &&
codes.length > 0 &&
codes.every((code) => CONNECT_CODES.has(code))
);
};
export interface WithRetryOptions extends RetryOptions { export interface WithRetryOptions extends RetryOptions {
isRetryable?: (err: unknown) => boolean; isRetryable?: (err: unknown) => boolean;
+91 -3
View File
@@ -639,6 +639,24 @@ describe("ApiClient retries", () => {
expect(policy.baseDelayMs).toBe(7); expect(policy.baseDelayMs).toBe(7);
expect(policy.maxDelayMs).toBe(11); expect(policy.maxDelayMs).toBe(11);
}); });
it("does not let a caller change its settings through that policy", async () => {
const { fetch, calls } = scriptedFetch(
textResponse("boom", 500),
textResponse("boom", 500),
textResponse("boom", 500),
);
const client = new ApiClient({
fetch,
retry: { ...noWait, attempts: 2 },
});
client.getRetryOptions().attempts = 3;
expect(client.getRetryOptions().attempts).toBe(2);
await expect(client.getJSON("/x")).rejects.toBeInstanceOf(ApiError);
expect(calls).toHaveLength(2);
});
}); });
describe("ApiClient timeouts", () => { describe("ApiClient timeouts", () => {
@@ -693,6 +711,51 @@ describe("ApiClient timeouts", () => {
expect(new Set(signals).size).toBe(3); expect(new Set(signals).size).toBe(3);
}, 5000); }, 5000);
it("gives every retrying entry point a fresh deadline per attempt", async () => {
// A refused connection is retried by every entry point, the
// non-idempotent ones included. If the deadline were created once,
// outside the retry, both attempts would carry the same signal.
const entryPoints: [
string,
() => Response,
(c: ApiClient) => unknown,
][] = [
["getJSON", () => jsonResponse({}), (c) => c.getJSON("/a")],
["postJSON", () => jsonResponse({}), (c) => c.postJSON("/b", {})],
["putJSON", () => jsonResponse({}), (c) => c.putJSON("/c", {})],
[
"putFile",
() => new Response(null, { status: 200 }),
(c) => c.putFile("https://s3.example/x", new Uint8Array([1])),
],
[
"getFileStream",
() => streamResponse(new Uint8Array([1])),
(c) => c.getFileStream(1),
],
[
"getThumbnailStream",
() => streamResponse(new Uint8Array([1])),
(c) => c.getThumbnailStream(1),
],
];
for (const [name, success, call] of entryPoints) {
const { fetch, calls } = scriptedFetch(
errnoError("ECONNREFUSED", "connect ECONNREFUSED"),
success(),
);
const client = new ApiClient({ fetch, retry: noWait });
await call(client);
expect(calls, name).toHaveLength(2);
const [first, second] = calls.map((c) => c.init?.signal);
expect(first, name).toBeInstanceOf(AbortSignal);
expect(second, name).toBeInstanceOf(AbortSignal);
expect(second, name).not.toBe(first);
}
});
it("recovers when a later attempt answers in time", async () => { it("recovers when a later attempt answers in time", async () => {
const { fetch, calls } = scriptedFetch(HANG, jsonResponse({ ok: 1 })); const { fetch, calls } = scriptedFetch(HANG, jsonResponse({ ok: 1 }));
const client = new ApiClient({ const client = new ApiClient({
@@ -820,9 +883,8 @@ describe("ApiClient error typing", () => {
describe("ApiClient non-idempotent requests", () => { describe("ApiClient non-idempotent requests", () => {
/** /**
* `postJSON` and `putJSON` carry quak's only requests that change server * `postJSON` and `putJSON` carry quak's requests that can change server
* state: `/users/srp/create-session`, `/users/two-factor/verify` — which * state; the README lists them under "Endpoints used".
* consumes one of a small number of 2FA attempts — and `/files/thumbnail`.
* *
* They are retried only on a failure that establishes no TCP connection to * They are retried only on a failure that establishes no TCP connection to
* the server ever existed — DNS produced no address, or the peer refused * the server ever existed — DNS produced no address, or the peer refused
@@ -922,4 +984,30 @@ describe("ApiClient non-idempotent requests", () => {
await refusedClient.updateThumbnail(1, "key", "header"); await refusedClient.updateThumbnail(1, "key", "header");
expect(refused.calls).toHaveLength(2); expect(refused.calls).toHaveLength(2);
}); });
it("does not follow or replay a redirect on POST or PUT", async () => {
// The origin has already received a request it answers with a
// redirect, so following it would let a refused connection to the
// redirect target pass for a request that never went out.
for (const send of [
(c: ApiClient) => c.postJSON("/users/ott", {}),
(c: ApiClient) => c.putJSON("/files/thumbnail", {}),
]) {
const { fetch, calls } = scriptedFetch(
new Response(null, {
status: 307,
headers: { location: "https://elsewhere.example/" },
}),
jsonResponse({}),
);
const client = new ApiClient({ fetch, retry: noWait });
const err: unknown = await send(client).catch((e: unknown) => e);
expect(calls[0]?.init?.redirect).toBe("manual");
expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).status).toBe(307);
expect(calls).toHaveLength(1);
}
});
}); });
+53 -2
View File
@@ -36,6 +36,7 @@ import {
lstatSync, lstatSync,
mkdirSync, mkdirSync,
mkdtempSync, mkdtempSync,
readdirSync,
readFileSync, readFileSync,
readlinkSync, readlinkSync,
rmSync, rmSync,
@@ -107,6 +108,29 @@ 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 {
@@ -136,9 +160,12 @@ const stubSource = (): StubSource => {
let root: string; let root: string;
const openLibrary = (source: ContentSource): Promise<Library> => const openLibrary = (
source: ContentSource,
client: MockClient = new MockClient(),
): Promise<Library> =>
Library.open({ Library.open({
client: new MockClient(), client,
cacheDirectory: join(root, "cache"), cacheDirectory: join(root, "cache"),
contentSource: source, contentSource: source,
refreshIntervalSeconds: 3600, refreshIntervalSeconds: 3600,
@@ -243,6 +270,30 @@ 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);
+18 -3
View File
@@ -165,14 +165,15 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
}, },
}; };
// Collection 2: "Work" with no magic metadata // Collection 2: "../Work" with no magic metadata. The server chose a name
// 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 = {
@@ -496,7 +497,8 @@ describe("quak backup-metadata", () => {
await runBackup(outDir); await runBackup(outDir);
const collDirs = readdirSync(join(outDir, "collections")); const collDirs = readdirSync(join(outDir, "collections"));
expect(collDirs.length).toBe(2); // "../Work" is sanitized into one directory name.
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"))!;
@@ -619,5 +621,18 @@ describe("quak backup-metadata", () => {
expect(fileMeta.imageMetadata.format).toBe("jpeg"); expect(fileMeta.imageMetadata.format).toBe("jpeg");
expect(fileMeta.imageMetadata.width).toBe(100); expect(fileMeta.imageMetadata.width).toBe(100);
expect(fileMeta.imageMetadata.height).toBe(80); expect(fileMeta.imageMetadata.height).toBe(80);
expect(fileMeta.imageMetadataError).toBeUndefined();
// File 200 has no original on the mock server, so extraction fails
// and the reason is recorded instead of the field being left out.
const workDir = collDirs.find((d) => d.includes("Work"))!;
const failedMeta = JSON.parse(
readFileSync(
join(outDir, "collections", workDir, "200.json"),
"utf-8",
),
);
expect(failedMeta.imageMetadata).toBeUndefined();
expect(failedMeta.imageMetadataError).toEqual(expect.any(String));
}); });
}); });
+122
View File
@@ -0,0 +1,122 @@
/**
* Tests for the JPEG EXIF scan behind `quak backup-metadata --exif`.
*
* The originals come from users' libraries, so a truncated or corrupt JPEG
* must neither hang the scan nor throw out of it, and a malformed file must be
* told apart from one that simply has no EXIF: the record carries the reason in
* `exifError`. Each input below is a short hand-built byte array.
*/
import { describe, expect, it } from "vitest";
import {
extractExifFromJpeg,
extractImageMetadata,
} from "../../src/metadata-backup.js";
const SOI = [0xff, 0xd8]; // start of image
const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan, where the scan stops
const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0"
// A big-endian TIFF block with one IFD entry: Orientation (0x0112), SHORT, 6.
const TIFF_ORIENTATION_6 = [
0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08, 0x00, 0x01, 0x01, 0x12,
0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00,
];
// An APP1 segment whose length field matches its data.
const app1 = (data: number[]): number[] => {
const len = data.length + 2;
return [0xff, 0xe1, len >> 8, len & 0xff, ...data];
};
const bytes = (...parts: number[][]): Uint8Array =>
new Uint8Array(parts.flat());
describe("extractExifFromJpeg", () => {
it("returns the EXIF segment of a valid JPEG", () => {
const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6];
const scan = extractExifFromJpeg(bytes(SOI, app1(data), SOS));
expect(scan.error).toBeUndefined();
expect([...scan.exif!]).toEqual(data);
});
it("returns nothing for a file that is not a JPEG", () => {
const png = bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
expect(extractExifFromJpeg(png)).toEqual({});
});
it("returns nothing for a JPEG without EXIF", () => {
const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
expect(extractExifFromJpeg(bytes(SOI, app0, SOS))).toEqual({});
});
it("reports a JPEG truncated inside a segment header", () => {
const scan = extractExifFromJpeg(bytes(SOI, [0xff, 0xe1, 0x00]));
expect(scan.exif).toBeUndefined();
expect(scan.error).toMatch(/truncated segment length/);
});
it("reports a JPEG that ends before the image data", () => {
const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
const scan = extractExifFromJpeg(bytes(SOI, app0));
expect(scan.error).toMatch(/ends before the image data/);
});
it("stops on a zero-length segment instead of looping", () => {
// A length of 0 would otherwise step the scan by 2 bytes at a time
// through the rest of the file, reading garbage as markers.
const zero = [0xff, 0xe0, 0x00, 0x00];
const scan = extractExifFromJpeg(
bytes(SOI, zero, zero, zero, zero, SOS),
);
expect(scan.error).toMatch(/segment length 0 at byte 2 is too small/);
});
it("stops on a segment length of 1", () => {
const scan = extractExifFromJpeg(
bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS),
);
expect(scan.error).toMatch(/segment length 1 at byte 2 is too small/);
});
it("reports a segment length that runs past the end of the file", () => {
// APP1 claims 0x4000 bytes but only the "Exif\0\0" header follows.
const scan = extractExifFromJpeg(
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
);
expect(scan.exif).toBeUndefined();
expect(scan.error).toMatch(/runs past the end of the file/);
});
});
describe("extractImageMetadata", () => {
it("parses EXIF from a valid JPEG", () => {
const meta = extractImageMetadata(
bytes(SOI, app1([...EXIF_HEADER, ...TIFF_ORIENTATION_6]), SOS),
);
expect(meta?.exifError).toBeUndefined();
expect(meta?.exif).toMatchObject({ Image: { Orientation: 6 } });
});
it("returns nothing for a file that is not a JPEG", () => {
const text = new TextEncoder().encode("just some text, not an image");
expect(extractImageMetadata(text)).toBeUndefined();
});
it("records the reason when the JPEG is malformed", () => {
const meta = extractImageMetadata(
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
);
expect(meta?.exif).toBeUndefined();
expect(meta?.exifError).toMatch(/runs past the end of the file/);
});
it("keeps the raw bytes and the reason when EXIF cannot be parsed", () => {
const data = [...EXIF_HEADER, 0x58, 0x58];
const meta = extractImageMetadata(bytes(SOI, app1(data), SOS));
expect(meta?.exif).toBeUndefined();
expect(meta?.exifRaw).toBe(Buffer.from(data).toString("base64"));
expect(meta?.exifError).toEqual(expect.any(String));
});
});
+18
View File
@@ -64,6 +64,24 @@ 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
@@ -0,0 +1,197 @@
/**
* 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`),
);
});
});
+78 -11
View File
@@ -49,6 +49,7 @@
import { import {
existsSync, existsSync,
mkdirSync,
readdirSync, readdirSync,
readFileSync, readFileSync,
rmSync, rmSync,
@@ -501,6 +502,22 @@ 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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -536,26 +553,59 @@ 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);
const titlePath = join(testDir, "fallback-name.png"); file.metadata.title = "fallback-name.png";
file.metadata.title = titlePath; const dir = mkdtempSync(join(testDir, "title-"));
const api = new ApiClient({ fetch: mockFetchForBody(ciphertext) }); const api = new ApiClient({ fetch: mockFetchForBody(ciphertext) });
const result = await downloadFile(api, file); const result = await inDirectory(dir, () => downloadFile(api, file));
expect(result.path).toBe(titlePath); expect(result.path).toBe("fallback-name.png");
expect(readFileSync(result.path)).toEqual(Buffer.from(plaintext)); expect(readFileSync(join(dir, "fallback-name.png"))).toEqual(
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 () => {
@@ -617,6 +667,23 @@ 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
@@ -0,0 +1,93 @@
// 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,6 +226,25 @@ 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 });
+33 -3
View File
@@ -146,10 +146,13 @@ const buildSharedRawCollection = (
const buildRawFile = ( const buildRawFile = (
collectionKey: Uint8Array, collectionKey: Uint8Array,
opts?: { opts?: {
title?: string; // Any JSON value; `undefined` leaves the title out of the metadata.
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();
@@ -158,8 +161,8 @@ const buildRawFile = (
collectionKey, collectionKey,
); );
const metadata = { const defaultMetadata = {
title: opts?.title ?? "IMG_0001.jpg", title: opts && "title" in opts ? 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,
@@ -167,6 +170,8 @@ 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));
@@ -321,6 +326,31 @@ 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();
+50
View File
@@ -0,0 +1,50 @@
// A checkout nested under `.claude/` must not add its tests to this suite.
// The test plants one in a temporary directory next to a real test file and
// asks vitest, with this repo's config, which test files it would run.
import { afterEach, describe, expect, it } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
let root = "";
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
const writeTest = (path: string): void => {
mkdirSync(join(root, path, ".."), { recursive: true });
writeFileSync(
join(root, path),
'import { it } from "vitest";\nit("runs", () => {});\n',
);
};
describe("vitest.config.ts", () => {
it("does not collect tests from a checkout nested under .claude/", () => {
root = mkdtempSync(join(tmpdir(), "quak-nested-checkout-"));
writeTest("test/real.test.ts");
writeTest(".claude/worktrees/other/test/real.test.ts");
const output = execFileSync(
process.execPath,
[
join(repoRoot, "node_modules/vitest/vitest.mjs"),
"list",
"--filesOnly",
"--config",
join(repoRoot, "vitest.config.ts"),
"--root",
root,
],
{ cwd: root, encoding: "utf-8" },
);
const files = output.split("\n").filter((line) => line !== "");
expect(files).toEqual(["test/real.test.ts"]);
});
});
+75 -4
View File
@@ -149,8 +149,11 @@ describe("isRetryable: transport failures", () => {
}); });
it("retries an errno carried on the error itself", () => { it("retries an errno carried on the error itself", () => {
// Every errno the classifier names, so none can be reclassified
// unnoticed.
for (const code of [ for (const code of [
"ECONNRESET", "ECONNRESET",
"ECONNABORTED",
"ETIMEDOUT", "ETIMEDOUT",
"EPIPE", "EPIPE",
"ENOTFOUND", "ENOTFOUND",
@@ -158,6 +161,8 @@ describe("isRetryable: transport failures", () => {
"ECONNREFUSED", "ECONNREFUSED",
"EHOSTUNREACH", "EHOSTUNREACH",
"ENETUNREACH", "ENETUNREACH",
"ENETRESET",
"ENETDOWN",
]) { ]) {
expect(isRetryable(errnoError(code))).toBe(true); expect(isRetryable(errnoError(code))).toBe(true);
} }
@@ -215,6 +220,27 @@ describe("isRetryable: transport failures", () => {
looped.cause = looped; looped.cause = looped;
expect(isRetryable(looped)).toBe(false); expect(isRetryable(looped)).toBe(false);
}); });
it("terminates on a cause chain that loops through two errors", () => {
const first: Error & { cause?: unknown } = new Error("first");
const second = new Error("second", { cause: first });
first.cause = second;
expect(isRetryable(first)).toBe(false);
});
it("reads the error and at most seven causes below it", () => {
// The walk is bounded at eight links. An errno at the eighth link is
// found; one at the ninth is not.
const buried = (causes: number): Error => {
let err = errnoError("ECONNRESET");
for (let i = 0; i < causes; i++) {
err = new Error(`wrapper ${i}`, { cause: err });
}
return err;
};
expect(isRetryable(buried(7))).toBe(true);
expect(isRetryable(buried(8))).toBe(false);
});
}); });
describe("isRetryable: stream truncation versus corruption", () => { describe("isRetryable: stream truncation versus corruption", () => {
@@ -279,10 +305,9 @@ describe("isSafeToReplay", () => {
* that is not the whole question: the other half is "could the first * that is not the whole question: the other half is "could the first
* attempt already have taken effect on the server?". * attempt already have taken effect on the server?".
* *
* quak's non-idempotent calls are `/users/srp/create-session`, * The calls this guards are the `POST` and `PUT` requests listed in the
* `/users/two-factor/verify` (which consumes one of a limited number of * README under "Endpoints used". A blind replay of some of them can do
* 2FA attempts) and `/files/thumbnail`. A blind replay of any of them can * real damage, so they retry only on the failures that establish no TCP
* do real damage, so they retry only on the failures that establish no TCP
* connection to the server ever existed — DNS produced no address, or the * connection to the server ever existed — DNS produced no address, or the
* peer refused the connection — and therefore that no request byte can * peer refused the connection — and therefore that no request byte can
* have been transmitted. * have been transmitted.
@@ -333,6 +358,52 @@ describe("isSafeToReplay", () => {
).toBe(false); ).toBe(false);
expect(isSafeToReplay(new TypeError("fetch failed"))).toBe(false); expect(isSafeToReplay(new TypeError("fetch failed"))).toBe(false);
}); });
it("does not replay any other errno the classifier names", () => {
for (const code of [
"ECONNRESET",
"ECONNABORTED",
"ETIMEDOUT",
"EPIPE",
"EHOSTUNREACH",
"ENETUNREACH",
"ENETRESET",
"ENETDOWN",
]) {
expect(isSafeToReplay(errnoError(code))).toBe(false);
}
});
it("does not replay a chain that also shows the request may have gone out", () => {
// A connect errno somewhere in the chain is not enough: any other
// errno beside it is doubt, and doubt is not replayed.
const reset = Object.assign(
new Error("read ECONNRESET", { cause: errnoError("ECONNREFUSED") }),
{ code: "ECONNRESET" },
);
const mixed = new TypeError("fetch failed", { cause: reset });
expect(isRetryable(mixed)).toBe(true);
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);
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+10
View File
@@ -0,0 +1,10 @@
import { configDefaults, defineConfig } from "vitest/config";
// vitest does not read .gitignore when looking for tests. A checkout nested
// under .claude/ has its own test/ tree, and without this exclude the suite
// runs once per nested checkout and still reports success.
export default defineConfig({
test: {
exclude: [...configDefaults.exclude, ".claude/**"],
},
});
+1 -8
View File
@@ -528,13 +528,6 @@
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"
@@ -1249,7 +1242,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@*, libsodium-wrappers-sumo@0.8.4: 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==