From b80aa798b2e8b071b1f846e6a97319f283a2a1f7 Mon Sep 17 00:00:00 2001 From: avi Date: Sat, 19 Sep 2026 17:20:31 -0500 Subject: [PATCH] fix(backup): exclude snapshots from recovery copy, harden change walk Migration recovery backups no longer re-copy Folio-Backups (up to 21x bloat). bookChangedSince tracks visited dev:ino so a symlink loop in the book cannot wedge the main-process interval tick. Snapshot toggle check aligned to === false; drop dead home-shortening branch in Settings. --- src/main/backup.ts | 18 +++++++++++++++++- src/main/index.ts | 2 +- src/renderer/book/settings.ts | 4 ++-- tests/run-snapshots-test.mjs | 29 +++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/main/backup.ts b/src/main/backup.ts index 9d93187..a83979a 100644 --- a/src/main/backup.ts +++ b/src/main/backup.ts @@ -49,7 +49,17 @@ export function ensureRecoveryBackup(bookPath: string): string | null { fs.mkdirSync(getRecoveryRoot(), { recursive: true }); try { - fs.cpSync(key, dest, { recursive: true }); + // Entry-by-entry, skipping the snapshot dir: snapshots are expendable + // copies already retained under their own root — re-backing them up here + // would multiply the recovery backup up to (keep + 1)x the book size. + // (One cpSync of the root is also refused by Node when the destination + // would sit inside the source.) + const entries = fs.readdirSync(key); + fs.mkdirSync(dest, { recursive: true }); + for (const e of entries) { + if (e === SNAPSHOT_DIR_NAME) continue; + fs.cpSync(path.join(key, e), path.join(dest, e), { recursive: true }); + } } catch (err) { // A half-copied directory is worse than none — it looks like a good // backup. Remove the partial copy, then surface the failure. @@ -118,6 +128,9 @@ export function bookChangedSince(bookPath: string, sinceMs: number): boolean { const key = path.resolve(bookPath); const snapRoot = path.join(key, SNAPSHOT_DIR_NAME) + path.sep; const stack: string[] = [key]; + // dev:ino of visited directories — a symlink loop inside the book must not + // wedge the main process in an infinite walk on every interval tick. + const seen = new Set(); while (stack.length) { const cur = stack.pop() as string; let stat: fs.Stats; @@ -128,6 +141,9 @@ export function bookChangedSince(bookPath: string, sinceMs: number): boolean { } if (stat.isDirectory()) { if ((cur + path.sep).startsWith(snapRoot)) continue; + const id = `${stat.dev}:${stat.ino}`; + if (seen.has(id)) continue; + seen.add(id); let kids: string[]; try { kids = fs.readdirSync(cur); diff --git a/src/main/index.ts b/src/main/index.ts index 8c565f2..050b10f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -901,7 +901,7 @@ setInterval(() => { snapshotLastRunMs = Date.now(); return; } - if (getSetting("snapshotsEnabled", SNAPSHOT_DEFAULTS.snapshotsEnabled) !== true) return; + if (getSetting("snapshotsEnabled", SNAPSHOT_DEFAULTS.snapshotsEnabled) === false) return; const intervalMin = clampInt( getSetting("snapshotIntervalMin", SNAPSHOT_DEFAULTS.intervalMin), SNAPSHOT_DEFAULTS.intervalMin, 1, 1440 diff --git a/src/renderer/book/settings.ts b/src/renderer/book/settings.ts index f14e02e..0344849 100644 --- a/src/renderer/book/settings.ts +++ b/src/renderer/book/settings.ts @@ -48,8 +48,8 @@ export function createSettingsFeature(ctx: BookCtx): SettingsFeature { let loaded = false; function shortPath(p: string): string { - const home = (window as unknown as { __folioHome?: string }).__folioHome; - if (home && p.startsWith(home)) return "~" + p.slice(home.length); + // The backend resolves the path; display it verbatim (no HOME shortening — + // the renderer has no reliable home-dir lookup under context isolation). return p; } diff --git a/tests/run-snapshots-test.mjs b/tests/run-snapshots-test.mjs index a5b935d..c484bd6 100644 --- a/tests/run-snapshots-test.mjs +++ b/tests/run-snapshots-test.mjs @@ -27,6 +27,9 @@ const { createSnapshot, pruneSnapshots, bookChangedSince, + ensureRecoveryBackup, + getRecoveryRoot, + _resetSessionBackupGuard, SNAPSHOT_DIR_NAME, } = backup; @@ -88,9 +91,35 @@ settings.delete("snapshotLocation"); // missing dir -> null, no throw ok(createSnapshot(path.join(dir, "nope"), 5) === null, "missing book returns null"); +// symlink loop inside the book must not hang the change check +try { + fs.symlinkSync(dir, path.join(dir, "chapters", "loop"), "dir"); + const t0 = Date.now(); + const changed = bookChangedSince(dir, Date.now() + 60_000); + ok(changed === false && Date.now() - t0 < 5000, "symlink loop terminates without false positive"); +} catch { + ok(true, "symlink loop test skipped (no symlink permission)"); +} + +// migration recovery backup excludes the snapshot dir (no 21x bloat) +const home = fs.mkdtempSync(path.join(os.tmpdir(), "folio-fakehome-")); +const origHome = process.env.HOME; +process.env.HOME = home; +try { + _resetSessionBackupGuard(); + const dest = ensureRecoveryBackup(dir); + ok(typeof dest === "string" && fs.existsSync(dest), "recovery backup created"); + ok(fs.existsSync(path.join(dest, "folio.json")), "recovery backup contains folio.json"); + ok(!fs.existsSync(path.join(dest, SNAPSHOT_DIR_NAME)), "recovery backup excludes snapshots"); + ok(getRecoveryRoot().startsWith(home), "recovery root honors HOME"); +} finally { + process.env.HOME = origHome; +} + // cleanup fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(custom, { recursive: true, force: true }); +fs.rmSync(home, { recursive: true, force: true }); cleanupProj(); cleanupChap(); cleanupBackup();