Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7cf3b77b5 |
@@ -323,7 +323,6 @@ 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.
|
||||||
|
|
||||||
@@ -378,23 +377,20 @@ 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`
|
||||||
send every `POST` and `PUT` in the endpoint list above; some of them change
|
reach `/users/srp/create-session`, `/users/two-factor/verify` — which consumes
|
||||||
server state, and `/users/two-factor/verify` consumes one of a small number of
|
one of a small number of second-factor attempts — and `/files/thumbnail`. They
|
||||||
second-factor attempts. They are retried only when every errno in the error's
|
are retried only on the three failures that establish no TCP connection to the
|
||||||
`cause` chain is one of the three that establish no TCP connection to the server
|
server ever existed, so no request byte can have been transmitted: `ENOTFOUND`
|
||||||
ever existed, so no request byte can have been transmitted: `ENOTFOUND` and
|
and `EAI_AGAIN` (name resolution produced no address) and `ECONNREFUSED` (the
|
||||||
`EAI_AGAIN` (name resolution produced no address) and `ECONNREFUSED` (the peer
|
peer refused the connection). A 5xx, a mid-flight reset and a deadline are all
|
||||||
refused the connection). A 5xx, a mid-flight reset and a deadline are all left
|
left to the caller, because each of them can happen after the server has already
|
||||||
to the caller, because each of them can happen after the server has already
|
acted. The routing errnos `EHOSTUNREACH`, `ENETUNREACH` and `ENETDOWN` are
|
||||||
acted. These two do not follow redirects either: a redirect means the server
|
excluded for the same reason, despite looking like connect-time failures: on
|
||||||
already received the request, so it is reported as an error and not retried. The
|
Linux an ICMP unreachable arriving mid-flight, or a local interface going down
|
||||||
routing errnos `EHOSTUNREACH`, `ENETUNREACH` and `ENETDOWN` are excluded for the
|
after the request was written, delivers them on an already-established socket.
|
||||||
same reason, despite looking like connect-time failures: on Linux an ICMP
|
They stay retryable for the idempotent calls. `putFile` is exempt: a presigned
|
||||||
unreachable arriving mid-flight, or a local interface going down after the
|
PUT stores one whole object at one key in one request, so replaying it has no
|
||||||
request was written, delivers them on an already-established socket. They stay
|
partial state to damage.
|
||||||
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
|
||||||
@@ -494,16 +490,6 @@ appears in. On subsequent runs, existing originals are skipped. If a download
|
|||||||
fails, the error is logged and the backup continues with the next file. The exit
|
fails, the error is logged and the backup continues with the next file. The exit
|
||||||
code is non-zero if any files failed.
|
code is non-zero if any files failed.
|
||||||
|
|
||||||
Each original is copied to a temporary file named
|
|
||||||
`.quak-backup-<fileID>.<ext>-<pid>-<random>.tmp` in the same directory, synced
|
|
||||||
to disk, and renamed into place, so an original is either complete or absent,
|
|
||||||
even after a power cut. A run that is killed can leave one of these temporary
|
|
||||||
files behind; the next backup deletes those whose process is no longer running.
|
|
||||||
Downloads and the content cache use the same scheme with `.quak-<random>.tmp`
|
|
||||||
names. The rename replaces whatever was at the destination rather than writing
|
|
||||||
through it: a symlink there is replaced, not followed, and the new file has the
|
|
||||||
temporary file's permissions, not those of the file it replaced.
|
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
- [x] Retry policy: no retry on 4xx, exponential backoff on 5xx and network
|
- [x] Retry policy: no retry on 4xx, exponential backoff on 5xx and network
|
||||||
|
|||||||
@@ -27,27 +27,6 @@ Tag v1.0.0.
|
|||||||
them with a fake client: session file modes, logout, the missing and corrupt
|
them with a fake client: session file modes, logout, the missing and corrupt
|
||||||
session paths, and the output and exit code of `whoami`, `collections`,
|
session paths, and the output and exit code of `whoami`, `collections`,
|
||||||
`files`, `get`, `get-thumb`, `backup` and `helper list-missing-thumbnails`.
|
`files`, `get`, `get-thumb`, `backup` and `helper list-missing-thumbnails`.
|
||||||
- 2026-09-23: Hardened the backup tree's atomic copy (issue 22). `copyAtomic`
|
|
||||||
fsyncs its temp file before the rename and the directory after it, through the
|
|
||||||
download writer's `fsyncPath`; each backup run deletes `.quak-backup-*.tmp`
|
|
||||||
files whose process is no longer running. The README backup layout names the
|
|
||||||
temp files and states that the rename replaces a symlink and takes the temp
|
|
||||||
file's permissions. Added tests for a missing and an unwritable destination
|
|
||||||
directory for `downloadFile` and `downloadThumbnail`.
|
|
||||||
- 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
|
- 2026-09-22: Stopped `make test` collecting tests from checkouts nested under
|
||||||
`.claude/` (issue 25). vitest ignores `.gitignore` when finding tests, so a
|
`.claude/` (issue 25). vitest ignores `.gitignore` when finding tests, so a
|
||||||
nested checkout ran the whole suite again; `vitest.config.ts` now adds
|
nested checkout ran the whole suite again; `vitest.config.ts` now adds
|
||||||
|
|||||||
+13
-14
@@ -146,9 +146,8 @@ export class ApiClient {
|
|||||||
// 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> {
|
||||||
@@ -229,15 +228,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}`;
|
||||||
// Not idempotent: a POST is replayed only when `isSafeToReplay`
|
// Idempotency: this reaches `/users/srp/create-session`,
|
||||||
// says no request byte can have reached the server. The endpoints
|
// `/users/two-factor/verify` and `/users/ott`, all of which change
|
||||||
// this covers are listed in the README under "Endpoints used".
|
// server state — verifying a second factor consumes one of a small
|
||||||
//
|
// number of attempts. So a POST is replayed only on a failure that
|
||||||
// Redirects are not followed. The origin has already received the
|
// establishes no TCP connection to the server ever existed: DNS
|
||||||
// request when it answers with one, so a connection refused by the
|
// produced no address, or the peer refused the connection. A 5xx, a
|
||||||
// redirect target would look replay-safe when it is not. The API has
|
// mid-flight reset, a routing errno (which Linux also delivers on an
|
||||||
// no legitimate redirect, so one surfaces as an `ApiError` with its
|
// established socket) and a timeout are all left to the caller,
|
||||||
// 3xx status, which is not retried.
|
// because each of them can occur after the server has already acted.
|
||||||
return withRetry(
|
return withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
const resp = await this._fetch(url, {
|
const resp = await this._fetch(url, {
|
||||||
@@ -246,7 +245,6 @@ 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);
|
||||||
@@ -305,7 +303,9 @@ 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 replay and redirect rules as `postJSON`, for the same reasons.
|
// Same idempotency rule as `postJSON`, for the same reason: this
|
||||||
|
// 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, {
|
||||||
@@ -314,7 +314,6 @@ 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);
|
||||||
|
|||||||
+9
-51
@@ -29,20 +29,19 @@
|
|||||||
// rather than counted forever, which would poison a scheduled backup's exit code.
|
// rather than counted forever, which would poison a scheduled backup's exit code.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
copyFileSync,
|
||||||
lstatSync,
|
lstatSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
readdirSync,
|
|
||||||
readFileSync,
|
readFileSync,
|
||||||
readlinkSync,
|
readlinkSync,
|
||||||
|
renameSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
statSync,
|
statSync,
|
||||||
symlinkSync,
|
symlinkSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { copyFile, rename, rm } from "node:fs/promises";
|
|
||||||
import { basename, dirname, join, relative } from "node:path";
|
import { basename, dirname, join, relative } from "node:path";
|
||||||
|
|
||||||
import { fsyncPath } from "./download/index.js";
|
|
||||||
import { safeExtension, sanitizeFileName } from "./filename.js";
|
import { safeExtension, sanitizeFileName } from "./filename.js";
|
||||||
import type { Collection, EnteFile } from "./model/types.js";
|
import type { Collection, EnteFile } from "./model/types.js";
|
||||||
|
|
||||||
@@ -155,12 +154,8 @@ const errorMessage = (err: unknown): string =>
|
|||||||
err instanceof Error ? err.message : String(err);
|
err instanceof Error ? err.message : String(err);
|
||||||
|
|
||||||
// Copy bytes into `dest` via a temp file in the same directory plus rename, so
|
// Copy bytes into `dest` via a temp file in the same directory plus rename, so
|
||||||
// `dest` appears only once it is whole ("present means complete"). As in the
|
// `dest` appears only once it is whole ("present means complete").
|
||||||
// download writer, the temp file is fsynced before the rename and the directory
|
const copyAtomic = (src: string, dest: string): void => {
|
||||||
// after it, so a power cut cannot leave a correctly named but short original.
|
|
||||||
// The temp name carries this process's ID so a later run can tell a leftover
|
|
||||||
// from a copy still in progress (see `removeLeftoverTempFiles`).
|
|
||||||
const copyAtomic = async (src: string, dest: string): Promise<void> => {
|
|
||||||
if (src === dest) return;
|
if (src === dest) return;
|
||||||
const tmp = join(
|
const tmp = join(
|
||||||
dirname(dest),
|
dirname(dest),
|
||||||
@@ -169,45 +164,10 @@ const copyAtomic = async (src: string, dest: string): Promise<void> => {
|
|||||||
.slice(2)}.tmp`,
|
.slice(2)}.tmp`,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await copyFile(src, tmp);
|
copyFileSync(src, tmp);
|
||||||
await fsyncPath(tmp);
|
renameSync(tmp, dest);
|
||||||
// `rename` replaces the destination's directory entry: an existing
|
|
||||||
// symlink at `dest` is replaced, not followed, and the new file has
|
|
||||||
// the temp file's permissions (copied from `src`).
|
|
||||||
await rename(tmp, dest);
|
|
||||||
await fsyncPath(dirname(dest));
|
|
||||||
} finally {
|
} finally {
|
||||||
await rm(tmp, { force: true });
|
rmSync(tmp, { force: true });
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// A process-ID check: signal 0 delivers nothing and only reports whether the
|
|
||||||
// process exists. EPERM means it exists but belongs to another user.
|
|
||||||
const isRunning = (pid: number): boolean => {
|
|
||||||
try {
|
|
||||||
process.kill(pid, 0);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Delete the temp files `copyAtomic` leaves behind when a backup is killed
|
|
||||||
// before its rename. Only files whose process is no longer running are
|
|
||||||
// removed, so a backup running at the same time keeps its own. A reused
|
|
||||||
// process ID can only keep a leftover a while longer, never remove a live one.
|
|
||||||
const removeLeftoverTempFiles = (dir: string): void => {
|
|
||||||
let names: string[];
|
|
||||||
try {
|
|
||||||
names = readdirSync(dir);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const name of names) {
|
|
||||||
const match = /^\.quak-backup-.*-(\d+)-[0-9a-z]*\.tmp$/.exec(name);
|
|
||||||
if (match && !isRunning(Number(match[1]))) {
|
|
||||||
rmSync(join(dir, name), { force: true });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -294,8 +254,6 @@ export const runBackup = async (
|
|||||||
mkdirSync(originalsDir, { recursive: true });
|
mkdirSync(originalsDir, { recursive: true });
|
||||||
mkdirSync(collectionsDir, { recursive: true });
|
mkdirSync(collectionsDir, { recursive: true });
|
||||||
if (includeThumbnails) mkdirSync(thumbnailsDir, { recursive: true });
|
if (includeThumbnails) mkdirSync(thumbnailsDir, { recursive: true });
|
||||||
removeLeftoverTempFiles(originalsDir);
|
|
||||||
removeLeftoverTempFiles(thumbnailsDir);
|
|
||||||
|
|
||||||
const ledgerPath = join(downloadDirectory, "failures.json");
|
const ledgerPath = join(downloadDirectory, "failures.json");
|
||||||
const ledger = loadLedger(ledgerPath);
|
const ledger = loadLedger(ledgerPath);
|
||||||
@@ -363,7 +321,7 @@ export const runBackup = async (
|
|||||||
try {
|
try {
|
||||||
log(`Fetching original ${file.metadata.title} (${fileID})...`);
|
log(`Fetching original ${file.metadata.title} (${fileID})...`);
|
||||||
const { path } = await lib.original(fileID);
|
const { path } = await lib.original(fileID);
|
||||||
await copyAtomic(path, dest);
|
copyAtomic(path, dest);
|
||||||
downloaded++;
|
downloaded++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(
|
log(
|
||||||
@@ -384,7 +342,7 @@ export const runBackup = async (
|
|||||||
if (isPresent(dest)) continue;
|
if (isPresent(dest)) continue;
|
||||||
try {
|
try {
|
||||||
const { path } = await lib.thumbnail(fileID);
|
const { path } = await lib.thumbnail(fileID);
|
||||||
await copyAtomic(path, dest);
|
copyAtomic(path, dest);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
recordFailure(
|
recordFailure(
|
||||||
file,
|
file,
|
||||||
|
|||||||
+6
-17
@@ -158,18 +158,6 @@ const streamDecrypt = async (
|
|||||||
return totalPlain;
|
return totalPlain;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fsync a file or a directory, so its contents (for a directory, its entries)
|
|
||||||
// are on stable storage. Exported for the backup tree's copy, which needs the
|
|
||||||
// same durability as the writer below.
|
|
||||||
export const fsyncPath = async (path: string): Promise<void> => {
|
|
||||||
const handle = await open(path, "r");
|
|
||||||
try {
|
|
||||||
await handle.sync();
|
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Stage a write to `destination` atomically and durably, then rename it into
|
// Stage a write to `destination` atomically and durably, then rename it into
|
||||||
// place. `fill` writes the contents into the open temp file handle — either the
|
// place. `fill` writes the contents into the open temp file handle — either the
|
||||||
// whole buffer at once (`writeAtomic`) or chunk by chunk as they decrypt
|
// whole buffer at once (`writeAtomic`) or chunk by chunk as they decrypt
|
||||||
@@ -205,15 +193,16 @@ const stageAtomic = async (
|
|||||||
} finally {
|
} finally {
|
||||||
await handle.close();
|
await handle.close();
|
||||||
}
|
}
|
||||||
// `rename` replaces the destination's directory entry rather than
|
|
||||||
// writing through it: an existing symlink at `destination` is
|
|
||||||
// replaced, not followed, and the new file has the temp file's
|
|
||||||
// permissions, not those of the file it replaced.
|
|
||||||
await rename(tmpPath, destination);
|
await rename(tmpPath, destination);
|
||||||
// Fsync the directory so the rename itself survives a crash: renaming
|
// Fsync the directory so the rename itself survives a crash: renaming
|
||||||
// over a synced temp file still leaves the new directory entry in the
|
// over a synced temp file still leaves the new directory entry in the
|
||||||
// page cache until the directory is synced.
|
// page cache until the directory is synced.
|
||||||
await fsyncPath(dir);
|
const dirHandle = await open(dir, "r");
|
||||||
|
try {
|
||||||
|
await dirHandle.sync();
|
||||||
|
} finally {
|
||||||
|
await dirHandle.close();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Best-effort cleanup. A failure to remove the temporary file must
|
// Best-effort cleanup. A failure to remove the temporary file must
|
||||||
// never replace the error that actually explains what went wrong.
|
// never replace the error that actually explains what went wrong.
|
||||||
|
|||||||
+62
-85
@@ -15,35 +15,18 @@ export interface MetadataBackupOptions {
|
|||||||
onProgress?: ProgressCallback;
|
onProgress?: ProgressCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the raw EXIF APP1 segment in JPEG bytes. Returns `exif` (the segment
|
// Extract the raw EXIF APP1 segment from JPEG bytes. Returns the EXIF
|
||||||
// data, starting at the "Exif\0\0" header) when there is one, nothing when the
|
// data buffer (starting after the APP1 length field, at the "Exif\0\0"
|
||||||
// bytes are not a JPEG or carry no EXIF, and `error` when the segment layout is
|
// header) or undefined if no APP1 marker is found.
|
||||||
// malformed. Each segment length is checked against the bytes that remain and
|
const extractExifFromJpeg = (buf: Uint8Array): Buffer | undefined => {
|
||||||
// each step moves forward by at least 4 bytes, so the scan ends on any input.
|
if (buf[0] !== 0xff || buf[1] !== 0xd8) return undefined;
|
||||||
export const extractExifFromJpeg = (
|
|
||||||
buf: Uint8Array,
|
|
||||||
): { exif?: Buffer; error?: string } => {
|
|
||||||
if (buf[0] !== 0xff || buf[1] !== 0xd8) return {};
|
|
||||||
let offset = 2;
|
let offset = 2;
|
||||||
while (offset < buf.length) {
|
while (offset < buf.length - 1) {
|
||||||
if (offset + 2 > buf.length)
|
if (buf[offset] !== 0xff) return undefined;
|
||||||
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) return {}; // start of scan, no more markers
|
if (marker === 0xda) break; // start of scan, no more markers
|
||||||
if (offset + 4 > buf.length)
|
if (offset + 3 >= buf.length) break;
|
||||||
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 (
|
||||||
@@ -52,70 +35,65 @@ export const extractExifFromJpeg = (
|
|||||||
buf[offset + 6] === 0x69 &&
|
buf[offset + 6] === 0x69 &&
|
||||||
buf[offset + 7] === 0x66
|
buf[offset + 7] === 0x66
|
||||||
) {
|
) {
|
||||||
return {
|
return Buffer.from(
|
||||||
exif: Buffer.from(
|
buf.buffer,
|
||||||
buf.buffer,
|
buf.byteOffset + offset + 4,
|
||||||
buf.byteOffset + offset + 4,
|
len - 2,
|
||||||
len - 2,
|
);
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
offset += 2 + len;
|
offset += 2 + len;
|
||||||
}
|
}
|
||||||
return { error: "file ends before the image data" };
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Extract dimensions, EXIF and XMP from a file's bytes. When the EXIF segment
|
const extractImageMetadata = (
|
||||||
// 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 decoded = jpeg.decode(fileBytes, {
|
const result: Record<string, unknown> = {};
|
||||||
useTArray: true,
|
|
||||||
formatAsRGBA: false,
|
|
||||||
});
|
|
||||||
result.format = "jpeg";
|
|
||||||
result.width = decoded.width;
|
|
||||||
result.height = decoded.height;
|
|
||||||
} catch {
|
|
||||||
// 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);
|
// Try to get dimensions from JPEG decode
|
||||||
if (error) result.exifError = error;
|
|
||||||
if (exif) {
|
|
||||||
try {
|
try {
|
||||||
result.exif = exifReader(exif);
|
const decoded = jpeg.decode(fileBytes, {
|
||||||
} catch (err) {
|
useTArray: true,
|
||||||
result.exifRaw = exif.toString("base64");
|
formatAsRGBA: false,
|
||||||
result.exifError = err instanceof Error ? err.message : String(err);
|
});
|
||||||
|
result.format = "jpeg";
|
||||||
|
result.width = decoded.width;
|
||||||
|
result.height = decoded.height;
|
||||||
|
} catch {
|
||||||
|
// Not a JPEG or corrupt; still try EXIF extraction
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Extract XMP (look for "http://ns.adobe.com/xap" in the bytes)
|
const exifBuf = extractExifFromJpeg(fileBytes);
|
||||||
const xmpStart = Buffer.from(fileBytes).indexOf("<?xpacket begin");
|
if (exifBuf) {
|
||||||
if (xmpStart !== -1) {
|
try {
|
||||||
const xmpEnd = Buffer.from(fileBytes).indexOf(
|
result.exif = exifReader(exifBuf);
|
||||||
"<?xpacket end",
|
} catch {
|
||||||
xmpStart,
|
result.exifRaw = exifBuf.toString("base64");
|
||||||
);
|
}
|
||||||
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;
|
// 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 {
|
||||||
|
return 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
|
||||||
@@ -125,9 +103,13 @@ export const extractImageMetadata = (
|
|||||||
const extractExif = async (
|
const extractExif = async (
|
||||||
photo: Photo,
|
photo: Photo,
|
||||||
): Promise<Record<string, unknown> | undefined> => {
|
): Promise<Record<string, unknown> | undefined> => {
|
||||||
const { path } = await photo.original();
|
try {
|
||||||
const fileBytes = new Uint8Array(readFileSync(path));
|
const { path } = await photo.original();
|
||||||
return extractImageMetadata(fileBytes);
|
const fileBytes = new Uint8Array(readFileSync(path));
|
||||||
|
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
|
||||||
@@ -233,13 +215,8 @@ 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...`);
|
||||||
try {
|
const exifData = await extractExif(photo);
|
||||||
const exifData = await extractExif(photo);
|
if (exifData) fileMeta.imageMetadata = exifData;
|
||||||
if (exifData) fileMeta.imageMetadata = exifData;
|
|
||||||
} catch (err) {
|
|
||||||
fileMeta.imageMetadataError =
|
|
||||||
err instanceof Error ? err.message : String(err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
writtenFileIDs.add(file.id);
|
writtenFileIDs.add(file.id);
|
||||||
|
|
||||||
|
|||||||
+12
-25
@@ -88,21 +88,18 @@ const MAX_CAUSE_DEPTH = 8;
|
|||||||
// errno on the error it throws — it hangs the underlying socket error off
|
// errno on the error it throws — it hangs the underlying socket error off
|
||||||
// `cause`, sometimes more than one level down — so a classifier that only read
|
// `cause`, sometimes more than one level down — so a classifier that only read
|
||||||
// the top-level error would see a bare `Error` and call every dropped
|
// the top-level error would see a bare `Error` and call every dropped
|
||||||
// connection permanent. `complete` is false when the walk stopped at the
|
// connection permanent.
|
||||||
// depth limit with more of the chain still below it.
|
const causeCodes = (err: unknown): string[] => {
|
||||||
const causeCodes = (err: unknown): { codes: string[]; complete: boolean } => {
|
|
||||||
const codes: string[] = [];
|
const codes: string[] = [];
|
||||||
let current: unknown = err;
|
let current: unknown = err;
|
||||||
for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) {
|
for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) {
|
||||||
if (current === null || typeof current !== "object") {
|
if (current === null || typeof current !== "object") break;
|
||||||
return { codes, complete: true };
|
|
||||||
}
|
|
||||||
const { code, cause } = current as { code?: unknown; cause?: unknown };
|
const { code, cause } = current as { code?: unknown; cause?: unknown };
|
||||||
if (typeof code === "string") codes.push(code);
|
if (typeof code === "string") codes.push(code);
|
||||||
if (cause === current) return { codes, complete: true };
|
if (cause === current) break;
|
||||||
current = cause;
|
current = cause;
|
||||||
}
|
}
|
||||||
return { codes, complete: current === null || typeof current !== "object" };
|
return codes;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAbort = (err: unknown): boolean => {
|
const isAbort = (err: unknown): boolean => {
|
||||||
@@ -148,15 +145,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).codes.some((code) => TRANSPORT_CODES.has(code));
|
return causeCodes(err).some((code) => TRANSPORT_CODES.has(code));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Could the first attempt already have taken effect on the server?
|
// Could the first attempt already have taken effect on the server?
|
||||||
//
|
//
|
||||||
// `isRetryable` is the wrong question for a request that changes state.
|
// `isRetryable` is the wrong question for a request that changes state.
|
||||||
// `postJSON` and `putJSON` use this for every `POST` and `PUT` listed in the
|
// quak's non-idempotent calls are `/users/srp/create-session`,
|
||||||
// README under "Endpoints used"; verifying a second factor, for one, consumes
|
// `/users/two-factor/verify` — which consumes one of a small number of 2FA
|
||||||
// one of a small number of attempts. They are replayed only on the failures in
|
// attempts — and `/files/thumbnail`. 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
|
||||||
@@ -165,19 +162,9 @@ 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. So every errno in the
|
// deadline says nothing at all about the server's state.
|
||||||
// cause chain must be a connect errno: one other errno anywhere in the chain
|
export const isSafeToReplay = (err: unknown): boolean =>
|
||||||
// is doubt, and doubt is not replayed. A chain longer than the walk is doubt
|
isRetryable(err) && causeCodes(err).some((code) => CONNECT_CODES.has(code));
|
||||||
// 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;
|
||||||
|
|||||||
+3
-91
@@ -639,24 +639,6 @@ 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", () => {
|
||||||
@@ -711,51 +693,6 @@ 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({
|
||||||
@@ -883,8 +820,9 @@ describe("ApiClient error typing", () => {
|
|||||||
|
|
||||||
describe("ApiClient non-idempotent requests", () => {
|
describe("ApiClient non-idempotent requests", () => {
|
||||||
/**
|
/**
|
||||||
* `postJSON` and `putJSON` carry quak's requests that can change server
|
* `postJSON` and `putJSON` carry quak's only requests that change server
|
||||||
* state; the README lists them under "Endpoints used".
|
* state: `/users/srp/create-session`, `/users/two-factor/verify` — which
|
||||||
|
* 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
|
||||||
@@ -984,30 +922,4 @@ 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-92
@@ -42,44 +42,15 @@ import {
|
|||||||
rmSync,
|
rmSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
import { Library } from "../../src/library/index.js";
|
import { Library } from "../../src/library/index.js";
|
||||||
import type { ContentSource } from "../../src/library/content.js";
|
import type { ContentSource } from "../../src/library/content.js";
|
||||||
import type { CollectionsPage, FilesPage } from "../../src/client.js";
|
import type { CollectionsPage, FilesPage } from "../../src/client.js";
|
||||||
import type { Collection, EnteFile } from "../../src/model/types.js";
|
import type { Collection, EnteFile } from "../../src/model/types.js";
|
||||||
|
|
||||||
// `open` and `rename` are wrapped to record, in order, every fsync and rename,
|
|
||||||
// so a test can pin the sequence "fsync the temp file, rename, fsync the
|
|
||||||
// directory" that makes a copied original survive a power cut. `vi.hoisted`
|
|
||||||
// because `vi.mock` factories run before module-level constants exist.
|
|
||||||
const fsEvents = vi.hoisted(() => [] as string[]);
|
|
||||||
|
|
||||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
open: async (
|
|
||||||
...args: Parameters<typeof actual.open>
|
|
||||||
): Promise<Awaited<ReturnType<typeof actual.open>>> => {
|
|
||||||
const handle = await actual.open(...args);
|
|
||||||
const realSync = handle.sync.bind(handle);
|
|
||||||
handle.sync = async (): Promise<void> => {
|
|
||||||
fsEvents.push(`sync:${String(args[0])}`);
|
|
||||||
await realSync();
|
|
||||||
};
|
|
||||||
return handle;
|
|
||||||
},
|
|
||||||
rename: async (from: string, to: string): Promise<void> => {
|
|
||||||
fsEvents.push(`rename:${to}`);
|
|
||||||
await actual.rename(from, to);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const USER_ID = 42;
|
const USER_ID = 42;
|
||||||
|
|
||||||
// Decrypted-byte length each stub original writes, keyed by fileID.
|
// Decrypted-byte length each stub original writes, keyed by fileID.
|
||||||
@@ -559,66 +530,4 @@ describe("lib.backup", () => {
|
|||||||
expect(readLedger(outDir).files["101"]!.attempts).toBe(1);
|
expect(readLedger(outDir).files["101"]!.attempts).toBe(1);
|
||||||
lib.close();
|
lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fsyncs a copied original before the rename and its directory after", async () => {
|
|
||||||
const lib = await openLibrary(stubSource());
|
|
||||||
const outDir = join(root, "backup");
|
|
||||||
const originals = join(outDir, "originals");
|
|
||||||
const dest = join(originals, "100.jpg");
|
|
||||||
fsEvents.length = 0;
|
|
||||||
|
|
||||||
await lib.backup({ downloadDirectory: outDir });
|
|
||||||
|
|
||||||
const at = fsEvents.indexOf(`rename:${dest}`);
|
|
||||||
expect(at).toBeGreaterThan(0);
|
|
||||||
expect(fsEvents[at - 1]).toMatch(
|
|
||||||
/^sync:.*\/\.quak-backup-100\.jpg-\d+-[0-9a-z]*\.tmp$/,
|
|
||||||
);
|
|
||||||
expect(fsEvents[at + 1]).toBe(`sync:${originals}`);
|
|
||||||
lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes temp files left by a killed backup but not those of one still running", async () => {
|
|
||||||
const outDir = join(root, "backup");
|
|
||||||
const originals = join(outDir, "originals");
|
|
||||||
mkdirSync(originals, { recursive: true });
|
|
||||||
// A child that has already exited: its process ID is not running.
|
|
||||||
const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid;
|
|
||||||
const leftover = `.quak-backup-100.jpg-${exitedPID}-abc123.tmp`;
|
|
||||||
// This test's own process stands in for a backup running at the same
|
|
||||||
// time.
|
|
||||||
const inProgress = `.quak-backup-101.jpg-${process.pid}-def456.tmp`;
|
|
||||||
writeFileSync(join(originals, leftover), "partial");
|
|
||||||
writeFileSync(join(originals, inProgress), "partial");
|
|
||||||
const lib = await openLibrary(stubSource());
|
|
||||||
|
|
||||||
await lib.backup({ downloadDirectory: outDir });
|
|
||||||
|
|
||||||
const names = readdirSync(originals);
|
|
||||||
expect(names).not.toContain(leftover);
|
|
||||||
expect(names).toContain(inProgress);
|
|
||||||
lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes leftover temp files in thumbnails/ but not those of a backup still running", async () => {
|
|
||||||
const outDir = join(root, "backup");
|
|
||||||
const thumbnails = join(outDir, "thumbnails");
|
|
||||||
mkdirSync(thumbnails, { recursive: true });
|
|
||||||
const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid;
|
|
||||||
const leftover = `.quak-backup-100.jpg-${exitedPID}-abc123.tmp`;
|
|
||||||
const inProgress = `.quak-backup-101.jpg-${process.pid}-def456.tmp`;
|
|
||||||
writeFileSync(join(thumbnails, leftover), "partial");
|
|
||||||
writeFileSync(join(thumbnails, inProgress), "partial");
|
|
||||||
const lib = await openLibrary(stubSource());
|
|
||||||
|
|
||||||
await lib.backup({
|
|
||||||
downloadDirectory: outDir,
|
|
||||||
includeThumbnails: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const names = readdirSync(thumbnails);
|
|
||||||
expect(names).not.toContain(leftover);
|
|
||||||
expect(names).toContain(inProgress);
|
|
||||||
lib.close();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -621,18 +621,5 @@ 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));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -48,7 +48,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
chmodSync,
|
|
||||||
existsSync,
|
existsSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
readdirSync,
|
readdirSync,
|
||||||
@@ -990,51 +989,6 @@ describe.each(entryPoints)(
|
|||||||
expect(readFileSync(outPath)).toEqual(Buffer.from(existing));
|
expect(readFileSync(outPath)).toEqual(Buffer.from(existing));
|
||||||
expect(readdirSync(dir)).toEqual(["rename-fails.bin"]);
|
expect(readdirSync(dir)).toEqual(["rename-fails.bin"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails without creating anything when the destination directory does not exist", async () => {
|
|
||||||
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
|
||||||
const { header, ciphertext } = encryptFileBody(
|
|
||||||
patternBytes(64, 33),
|
|
||||||
key,
|
|
||||||
);
|
|
||||||
const { api, file } = fixtureFor(key, header, ciphertext);
|
|
||||||
const dir = freshDir();
|
|
||||||
const outPath = join(dir, "missing", "never.bin");
|
|
||||||
|
|
||||||
await expect(download(api, file, outPath)).rejects.toMatchObject({
|
|
||||||
code: "ENOENT",
|
|
||||||
});
|
|
||||||
|
|
||||||
// The missing directory is not created on the caller's behalf.
|
|
||||||
expect(readdirSync(dir)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Root ignores directory permissions, so this cannot fail as root
|
|
||||||
// (the Docker test image runs as root).
|
|
||||||
it.skipIf(process.getuid?.() === 0)(
|
|
||||||
"fails without creating anything when the destination directory is not writable",
|
|
||||||
async () => {
|
|
||||||
const key =
|
|
||||||
sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
|
||||||
const { header, ciphertext } = encryptFileBody(
|
|
||||||
patternBytes(64, 34),
|
|
||||||
key,
|
|
||||||
);
|
|
||||||
const { api, file } = fixtureFor(key, header, ciphertext);
|
|
||||||
const dir = freshDir();
|
|
||||||
const outPath = join(dir, "never.bin");
|
|
||||||
chmodSync(dir, 0o500);
|
|
||||||
try {
|
|
||||||
await expect(
|
|
||||||
download(api, file, outPath),
|
|
||||||
).rejects.toMatchObject({ code: "EACCES" });
|
|
||||||
} finally {
|
|
||||||
chmodSync(dir, 0o700);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(readdirSync(dir)).toEqual([]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -149,11 +149,8 @@ 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",
|
||||||
@@ -161,8 +158,6 @@ describe("isRetryable: transport failures", () => {
|
|||||||
"ECONNREFUSED",
|
"ECONNREFUSED",
|
||||||
"EHOSTUNREACH",
|
"EHOSTUNREACH",
|
||||||
"ENETUNREACH",
|
"ENETUNREACH",
|
||||||
"ENETRESET",
|
|
||||||
"ENETDOWN",
|
|
||||||
]) {
|
]) {
|
||||||
expect(isRetryable(errnoError(code))).toBe(true);
|
expect(isRetryable(errnoError(code))).toBe(true);
|
||||||
}
|
}
|
||||||
@@ -220,27 +215,6 @@ 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", () => {
|
||||||
@@ -305,9 +279,10 @@ 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?".
|
||||||
*
|
*
|
||||||
* The calls this guards are the `POST` and `PUT` requests listed in the
|
* quak's non-idempotent calls are `/users/srp/create-session`,
|
||||||
* README under "Endpoints used". A blind replay of some of them can do
|
* `/users/two-factor/verify` (which consumes one of a limited number of
|
||||||
* real damage, so they retry only on the failures that establish no TCP
|
* 2FA attempts) and `/files/thumbnail`. A blind replay of any of them can
|
||||||
|
* 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.
|
||||||
@@ -358,52 +333,6 @@ 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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user