Harden the backup tree's atomic copy (closes #22) #85

Merged
clawbot merged 1 commits from issue-22-atomic-write-edges into next2 2026-09-23 02:44:47 +02:00
6 changed files with 223 additions and 16 deletions
Showing only changes of commit 7102e3443e - Show all commits
+10
View File
@@ -494,6 +494,16 @@ 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
+7
View File
@@ -18,6 +18,13 @@ Tag v1.0.0.
# Completed Steps # Completed Steps
- 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 - 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 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 under 2 stop the scan, so a truncated or corrupt original can neither throw
+51 -9
View File
@@ -29,19 +29,20 @@
// 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";
@@ -154,8 +155,12 @@ 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"). // `dest` appears only once it is whole ("present means complete"). As in the
const copyAtomic = (src: string, dest: string): void => { // download writer, the temp file is fsynced before the rename and the directory
// 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),
@@ -164,10 +169,45 @@ const copyAtomic = (src: string, dest: string): void => {
.slice(2)}.tmp`, .slice(2)}.tmp`,
); );
try { try {
copyFileSync(src, tmp); await copyFile(src, tmp);
renameSync(tmp, dest); await fsyncPath(tmp);
// `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 {
rmSync(tmp, { force: true }); await rm(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 });
}
} }
}; };
@@ -254,6 +294,8 @@ 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);
@@ -321,7 +363,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);
copyAtomic(path, dest); await copyAtomic(path, dest);
downloaded++; downloaded++;
} catch (err) { } catch (err) {
log( log(
@@ -342,7 +384,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);
copyAtomic(path, dest); await copyAtomic(path, dest);
} catch (err) { } catch (err) {
recordFailure( recordFailure(
file, file,
+17 -6
View File
@@ -158,6 +158,18 @@ 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
@@ -193,16 +205,15 @@ 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.
const dirHandle = await open(dir, "r"); await fsyncPath(dir);
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.
+92 -1
View File
@@ -42,15 +42,44 @@ 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 } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } 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.
@@ -530,4 +559,66 @@ 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();
});
}); });
+46
View File
@@ -48,6 +48,7 @@
*/ */
import { import {
chmodSync,
existsSync, existsSync,
mkdirSync, mkdirSync,
readdirSync, readdirSync,
@@ -989,6 +990,51 @@ 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([]);
},
);
}, },
); );