Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f788c0adc8 |
@@ -426,18 +426,13 @@ decides how to persist sessions.
|
||||
`client.toJSON()` returns a `ClientSnapshot` (a plain serializable object with
|
||||
base64-encoded keys) that the consumer can write to disk, a database, or
|
||||
whatever else fits their use case. `Client.fromJSON(snapshot)` restores a
|
||||
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.
|
||||
working client from that snapshot without re-authenticating.
|
||||
|
||||
The CLI stores the snapshot at the platform-appropriate data directory via
|
||||
`env-paths`: `~/Library/Application Support/quak/session.json` on macOS,
|
||||
`$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
|
||||
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.
|
||||
you would treat the password itself.
|
||||
|
||||
### CLI surface
|
||||
|
||||
|
||||
@@ -25,24 +25,6 @@ Tag v1.0.0.
|
||||
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: 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
|
||||
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()`
|
||||
|
||||
+20
-16
@@ -2,7 +2,13 @@
|
||||
|
||||
import { input, password as passwordPrompt } from "@inquirer/prompts";
|
||||
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 { Command } from "commander";
|
||||
import envPaths from "env-paths";
|
||||
@@ -16,7 +22,6 @@ import {
|
||||
thumbnailName,
|
||||
} from "../src/cli-output.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 {
|
||||
listMissingThumbnails,
|
||||
@@ -26,6 +31,15 @@ import {
|
||||
const paths = envPaths("quak", { suffix: "" });
|
||||
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 => {
|
||||
mkdirSync(paths.data, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(sessionPath, JSON.stringify(snapshot, null, 2), {
|
||||
@@ -34,23 +48,14 @@ const saveSession = (snapshot: ClientSnapshot): void => {
|
||||
};
|
||||
|
||||
const requireSession = (): Client => {
|
||||
let client: Client | null;
|
||||
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) {
|
||||
const snapshot = loadSession();
|
||||
if (!snapshot) {
|
||||
stderr.write(
|
||||
`Not logged in. Run "quak login" first.\nSession file: ${sessionPath}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return client;
|
||||
return Client.fromJSON(snapshot);
|
||||
};
|
||||
|
||||
const prompt = async (message: string): Promise<string> => input({ message });
|
||||
@@ -152,8 +157,7 @@ program
|
||||
program
|
||||
.command("whoami")
|
||||
.description("Print the logged-in account")
|
||||
.action(async () => {
|
||||
await init();
|
||||
.action(() => {
|
||||
const client = requireSession();
|
||||
const info = client.whoami();
|
||||
stdout.write(JSON.stringify(info) + "\n");
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.38.0",
|
||||
"@types/libsodium-wrappers-sumo": "0.8.2",
|
||||
"@types/node": "22.18.13",
|
||||
"eslint": "9.38.0",
|
||||
"prettier": "3.8.1",
|
||||
|
||||
@@ -139,10 +139,6 @@ export class ApiClient {
|
||||
this.token = undefined;
|
||||
}
|
||||
|
||||
getAuthToken(): string | undefined {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
// The policy this client was configured with, so that a caller wrapping a
|
||||
// whole operation in its own `withRetry` — the download layer — runs under
|
||||
// the same settings rather than under the library defaults.
|
||||
|
||||
+11
-8
@@ -40,9 +40,8 @@ import {
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} 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";
|
||||
|
||||
export type ProgressCallback = (message: string) => void;
|
||||
@@ -109,11 +108,16 @@ interface FailureEntry {
|
||||
|
||||
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
|
||||
// title (or `.bin`). Matches the content cache's own naming so a present check
|
||||
// lines up with what a fetch would write.
|
||||
const originalName = (file: EnteFile): string =>
|
||||
`${file.id}${safeExtension(file.metadata.title)}`;
|
||||
const originalName = (file: EnteFile): string => {
|
||||
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:
|
||||
// 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.
|
||||
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);
|
||||
mkdirSync(colDir, { recursive: true });
|
||||
|
||||
@@ -377,9 +381,8 @@ export const runBackup = async (
|
||||
if (!includeOriginals) continue;
|
||||
const orig = join(originalsDir, originalName(file));
|
||||
if (!isPresent(orig)) continue;
|
||||
const linkName = sanitizeFileName(
|
||||
file.metadata.title,
|
||||
`file-${file.id}`,
|
||||
const linkName = sanitizePath(
|
||||
file.metadata.title || `file-${file.id}`,
|
||||
);
|
||||
const linkPath = join(colDir, linkName);
|
||||
try {
|
||||
|
||||
+3
-6
@@ -9,7 +9,6 @@
|
||||
// `metadata.title`, and issue #52 requires that output stay byte-identical, so
|
||||
// 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";
|
||||
|
||||
// One row of `quak files --json`.
|
||||
@@ -33,11 +32,9 @@ export const fileListRow = (file: EnteFile): FileListRow => ({
|
||||
export const fileListLine = (file: EnteFile): string =>
|
||||
`${file.id}\t${file.metadata.fileType}\t${file.metadata.title}`;
|
||||
|
||||
// Default output path for `quak get` when `--out` is not given. The title comes
|
||||
// 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` when `--out` is not given.
|
||||
export const originalName = (file: EnteFile): string => file.metadata.title;
|
||||
|
||||
// Default output path for `quak get-thumb` when `--out` is not given.
|
||||
export const thumbnailName = (file: EnteFile): string =>
|
||||
`thumb_${originalName(file)}`;
|
||||
`thumb_${file.metadata.title}`;
|
||||
|
||||
@@ -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
@@ -125,57 +125,18 @@ export class Client {
|
||||
);
|
||||
}
|
||||
|
||||
// Restore a client from a `toJSON()` snapshot. The snapshot usually comes
|
||||
// straight from `JSON.parse` of a file on disk, so every field is checked
|
||||
// before use; a bad one throws an error naming it. Needs `init()` first.
|
||||
static fromJSON(snapshot: unknown, apiOptions?: ApiClientOptions): Client {
|
||||
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,
|
||||
});
|
||||
static fromJSON(
|
||||
snapshot: ClientSnapshot,
|
||||
apiOptions?: ApiClientOptions,
|
||||
): Client {
|
||||
const api = new ApiClient({ ...apiOptions, authToken: snapshot.token });
|
||||
return new Client(
|
||||
api,
|
||||
s.email as string,
|
||||
s.userID as number,
|
||||
key("masterKey"),
|
||||
key("secretKey"),
|
||||
key("publicKey"),
|
||||
snapshot.email,
|
||||
snapshot.userID,
|
||||
fromBase64(snapshot.masterKey),
|
||||
fromBase64(snapshot.secretKey),
|
||||
fromBase64(snapshot.publicKey),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -203,29 +164,19 @@ export class Client {
|
||||
|
||||
toJSON(): ClientSnapshot {
|
||||
this.assertLoggedIn();
|
||||
const token = this.api.getAuthToken();
|
||||
if (!token) {
|
||||
throw new Error("Cannot serialize client: it has no auth token");
|
||||
}
|
||||
return {
|
||||
email: this.email,
|
||||
userID: this.userID,
|
||||
token,
|
||||
token: this.api["token"]!,
|
||||
masterKey: toBase64(this.masterKey),
|
||||
secretKey: toBase64(this.secretKey),
|
||||
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 {
|
||||
this.loggedOut = true;
|
||||
this.api.clearAuthToken();
|
||||
this.masterKey.fill(0);
|
||||
this.secretKey.fill(0);
|
||||
this.publicKey.fill(0);
|
||||
}
|
||||
|
||||
// Enumerate collections changed since `sinceTime`. Live collections are
|
||||
@@ -241,8 +192,6 @@ export class Client {
|
||||
const { collections: raws } = await this.api.getJSON<{
|
||||
collections: RawCollection[];
|
||||
}>("/collections/v2", { sinceTime: args.sinceTime });
|
||||
// logout() may have zeroed the keys while the request was in flight.
|
||||
this.assertLoggedIn();
|
||||
|
||||
const collections: Collection[] = [];
|
||||
const deleted: number[] = [];
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
streamTagFinal,
|
||||
} from "../crypto/index.js";
|
||||
import { TruncatedStreamError } from "../errors.js";
|
||||
import { sanitizeFileName } from "../filename.js";
|
||||
import { withRetry } from "../retry.js";
|
||||
import type { ApiClient } from "../api/client.js";
|
||||
import type { EnteFile } from "../model/types.js";
|
||||
@@ -287,10 +286,7 @@ export const downloadFile = async (
|
||||
outPath?: string,
|
||||
onProgress?: ProgressCallback,
|
||||
): Promise<DownloadResult> => {
|
||||
// `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 resolvedPath = outPath ?? file.metadata.title;
|
||||
const header = fromBase64(file.file.decryptionHeader);
|
||||
const bytesWritten = await fetchAndDecrypt(
|
||||
api,
|
||||
@@ -309,9 +305,7 @@ export const downloadThumbnail = async (
|
||||
outPath?: string,
|
||||
onProgress?: ProgressCallback,
|
||||
): Promise<DownloadResult> => {
|
||||
const resolvedPath =
|
||||
outPath ??
|
||||
`thumb_${sanitizeFileName(file.metadata.title, `file-${file.id}`)}`;
|
||||
const resolvedPath = outPath ?? `thumb_${file.metadata.title}`;
|
||||
const header = fromBase64(file.thumbnail.decryptionHeader);
|
||||
const bytesWritten = await fetchAndDecrypt(
|
||||
api,
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
downloadThumbnail,
|
||||
type ProgressCallback,
|
||||
} from "../download/index.js";
|
||||
import { safeExtension } from "../filename.js";
|
||||
import type { EnteFile } from "../model/types.js";
|
||||
import type { Priority, RequestPools } from "./pools.js";
|
||||
|
||||
@@ -210,8 +209,10 @@ class AbortDrop extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const originalName = (file: EnteFile): string =>
|
||||
`${file.id}${safeExtension(file.metadata.title)}`;
|
||||
const originalName = (file: EnteFile): string => {
|
||||
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 cache writes (`<digits><ext>`).
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as jpeg from "jpeg-js";
|
||||
import exifReader from "exif-reader";
|
||||
import type { Client } from "./client.js";
|
||||
import type { Library, Photo } from "./library/index.js";
|
||||
import { sanitizeFileName } from "./filename.js";
|
||||
import { fetchMLData } from "./mldata-fetch.js";
|
||||
import type { EnteFile } from "./model/types.js";
|
||||
|
||||
@@ -15,6 +14,9 @@ export interface MetadataBackupOptions {
|
||||
onProgress?: ProgressCallback;
|
||||
}
|
||||
|
||||
const sanitizePath = (name: string): string =>
|
||||
name.replace(/[/\\:*?"<>|]/g, "_").replace(/^\.+/, "_");
|
||||
|
||||
// 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"
|
||||
// header) or undefined if no APP1 marker is found.
|
||||
@@ -149,7 +151,7 @@ export const runMetadataBackup = async (
|
||||
const col = lib.getCollection(album.collectionID);
|
||||
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);
|
||||
mkdirSync(colDir, { recursive: true });
|
||||
|
||||
|
||||
+1
-10
@@ -98,18 +98,9 @@ export const decryptFile = (
|
||||
key,
|
||||
);
|
||||
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 = {
|
||||
// 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 : "",
|
||||
title: metadataJSON.title ?? "",
|
||||
fileType: parseFileType(metadataJSON.fileType ?? -1),
|
||||
creationTime: metadataJSON.creationTime ?? 0,
|
||||
modificationTime: metadataJSON.modificationTime ?? 0,
|
||||
|
||||
+8
-13
@@ -88,21 +88,18 @@ const MAX_CAUSE_DEPTH = 8;
|
||||
// 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
|
||||
// the top-level error would see a bare `Error` and call every dropped
|
||||
// connection permanent. `complete` is false when the walk stopped at the
|
||||
// depth limit with more of the chain still below it.
|
||||
const causeCodes = (err: unknown): { codes: string[]; complete: boolean } => {
|
||||
// connection permanent.
|
||||
const causeCodes = (err: unknown): string[] => {
|
||||
const codes: string[] = [];
|
||||
let current: unknown = err;
|
||||
for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) {
|
||||
if (current === null || typeof current !== "object") {
|
||||
return { codes, complete: true };
|
||||
}
|
||||
if (current === null || typeof current !== "object") break;
|
||||
const { code, cause } = current as { code?: unknown; cause?: unknown };
|
||||
if (typeof code === "string") codes.push(code);
|
||||
if (cause === current) return { codes, complete: true };
|
||||
if (cause === current) break;
|
||||
current = cause;
|
||||
}
|
||||
return { codes, complete: current === null || typeof current !== "object" };
|
||||
return codes;
|
||||
};
|
||||
|
||||
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
|
||||
// count.
|
||||
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?
|
||||
@@ -167,13 +164,11 @@ export const isRetryable = (err: unknown): boolean => {
|
||||
// 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
|
||||
// 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
|
||||
// too: the links below the limit were never read.
|
||||
// is doubt, and doubt is not replayed.
|
||||
export const isSafeToReplay = (err: unknown): boolean => {
|
||||
const { codes, complete } = causeCodes(err);
|
||||
const codes = causeCodes(err);
|
||||
return (
|
||||
isRetryable(err) &&
|
||||
complete &&
|
||||
codes.length > 0 &&
|
||||
codes.every((code) => CONNECT_CODES.has(code))
|
||||
);
|
||||
|
||||
+2
-53
@@ -36,7 +36,6 @@ import {
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
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
|
||||
// told to fail one fileID's original, to exercise per-file resilience.
|
||||
interface StubSource extends ContentSource {
|
||||
@@ -160,12 +136,9 @@ const stubSource = (): StubSource => {
|
||||
|
||||
let root: string;
|
||||
|
||||
const openLibrary = (
|
||||
source: ContentSource,
|
||||
client: MockClient = new MockClient(),
|
||||
): Promise<Library> =>
|
||||
const openLibrary = (source: ContentSource): Promise<Library> =>
|
||||
Library.open({
|
||||
client,
|
||||
client: new MockClient(),
|
||||
cacheDirectory: join(root, "cache"),
|
||||
contentSource: source,
|
||||
refreshIntervalSeconds: 3600,
|
||||
@@ -270,30 +243,6 @@ describe("lib.backup", () => {
|
||||
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 () => {
|
||||
const source = stubSource();
|
||||
const lib = await openLibrary(source);
|
||||
|
||||
@@ -165,15 +165,14 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
|
||||
},
|
||||
};
|
||||
|
||||
// Collection 2: "../Work" with no magic metadata. The server chose a name
|
||||
// that tries to climb out of the backup directory.
|
||||
// Collection 2: "Work" with no magic metadata
|
||||
const ck2 = sodium.crypto_secretbox_keygen();
|
||||
const { ciphertext: encCK2, nonce: ck2N } = encryptSecretbox(
|
||||
ck2,
|
||||
masterKey,
|
||||
);
|
||||
const { ciphertext: encCN2, nonce: cn2N } = encryptSecretbox(
|
||||
new TextEncoder().encode("../Work"),
|
||||
new TextEncoder().encode("Work"),
|
||||
ck2,
|
||||
);
|
||||
const rawColl2 = {
|
||||
@@ -497,8 +496,7 @@ describe("quak backup-metadata", () => {
|
||||
await runBackup(outDir);
|
||||
|
||||
const collDirs = readdirSync(join(outDir, "collections"));
|
||||
// "../Work" is sanitized into one directory name.
|
||||
expect(collDirs.sort()).toEqual(["10-Vacation", "20-__Work"]);
|
||||
expect(collDirs.length).toBe(2);
|
||||
|
||||
// Find the Vacation collection dir (prefixed with ID)
|
||||
const vacDir = collDirs.find((d) => d.includes("Vacation"))!;
|
||||
|
||||
@@ -64,24 +64,6 @@ describe("CLI file output (issue #52)", () => {
|
||||
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", () => {
|
||||
const record = deriveRecords([], [renamedFile]).photos.get(100);
|
||||
// The projection prefers the edits and reports milliseconds; the CLI
|
||||
|
||||
@@ -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`),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,6 @@
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
@@ -502,22 +501,6 @@ const entryPoints = [
|
||||
{ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -553,59 +536,26 @@ describe("downloadFile", () => {
|
||||
});
|
||||
|
||||
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 key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||
const { header, ciphertext } = encryptFileBody(plaintext, key);
|
||||
const thumbPush =
|
||||
sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
|
||||
const file = buildMockEnteFile(key, header, thumbPush.header);
|
||||
file.metadata.title = "fallback-name.png";
|
||||
const dir = mkdtempSync(join(testDir, "title-"));
|
||||
const titlePath = join(testDir, "fallback-name.png");
|
||||
file.metadata.title = titlePath;
|
||||
|
||||
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(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);
|
||||
expect(result.path).toBe(titlePath);
|
||||
expect(readFileSync(result.path)).toEqual(Buffer.from(plaintext));
|
||||
});
|
||||
|
||||
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(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"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -226,25 +226,6 @@ describe("ContentCache.original / thumbnail", () => {
|
||||
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 () => {
|
||||
const downloadDirectory = join(root, "backup");
|
||||
mkdirSync(join(downloadDirectory, "originals"), { recursive: true });
|
||||
|
||||
@@ -146,13 +146,10 @@ const buildSharedRawCollection = (
|
||||
const buildRawFile = (
|
||||
collectionKey: Uint8Array,
|
||||
opts?: {
|
||||
// Any JSON value; `undefined` leaves the title out of the metadata.
|
||||
title?: unknown;
|
||||
title?: string;
|
||||
fileType?: number;
|
||||
creationTime?: number;
|
||||
info?: { fileSize?: number; thumbSize?: number };
|
||||
// Replaces the whole metadata JSON value.
|
||||
metadata?: unknown;
|
||||
},
|
||||
): RawEnteFile => {
|
||||
const fileKey = sodium.crypto_secretbox_keygen();
|
||||
@@ -161,8 +158,8 @@ const buildRawFile = (
|
||||
collectionKey,
|
||||
);
|
||||
|
||||
const defaultMetadata = {
|
||||
title: opts && "title" in opts ? opts.title : "IMG_0001.jpg",
|
||||
const metadata = {
|
||||
title: opts?.title ?? "IMG_0001.jpg",
|
||||
fileType: opts?.fileType ?? 0,
|
||||
creationTime: opts?.creationTime ?? 1700000000000000,
|
||||
modificationTime: 1700000000000000,
|
||||
@@ -170,8 +167,6 @@ const buildRawFile = (
|
||||
longitude: 2.3522,
|
||||
hash: "abcdef1234567890",
|
||||
};
|
||||
const metadata =
|
||||
opts && "metadata" in opts ? opts.metadata : defaultMetadata;
|
||||
// File metadata is encrypted as a single-chunk secretstream blob
|
||||
// (not secretbox). The decryptionHeader is the secretstream init header.
|
||||
const metadataBytes = new TextEncoder().encode(JSON.stringify(metadata));
|
||||
@@ -326,31 +321,6 @@ describe("model.decryptFile", () => {
|
||||
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", () => {
|
||||
// Ente uses: 0=image, 1=video, 2=livePhoto
|
||||
const masterKey = sodium.crypto_secretbox_keygen();
|
||||
|
||||
@@ -385,25 +385,6 @@ describe("isSafeToReplay", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -528,6 +528,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
|
||||
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":
|
||||
version "22.18.13"
|
||||
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"
|
||||
integrity sha512-TMtHShQfVVsaxDygyapvUC3o7YsPgXa/hRWeIgzyFz6w5k/1hirGptCxp1U7XwW3rCskaTTYKgV10v86UiGgNw==
|
||||
|
||||
libsodium-wrappers-sumo@0.8.4:
|
||||
libsodium-wrappers-sumo@*, libsodium-wrappers-sumo@0.8.4:
|
||||
version "0.8.4"
|
||||
resolved "https://registry.yarnpkg.com/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.4.tgz#6656a3e7e0551ecce08ddee4bfb501a092eac6fa"
|
||||
integrity sha512-ql7hcgulKZ3ekfa2DGAogcCKsWU0diA/0nArz1CFzh93WQdb46/Kj18ka/Hifq6uA3Ush34Pc6vU/6HXeRwUkg==
|
||||
|
||||
Reference in New Issue
Block a user