/** * Unit tests for backups/restore-script.ts. The new-location builder is a simple * argv check. The in-place builder is verified structurally AND with a real-fs * round-trip that models restic (writing into /volumes/) and * proves the flatten + swap lands the restored contents in the live root. */ import { describe, it, expect } from 'bun:test'; import { buildInPlaceRestore, buildNewLocationRestore, buildCloneRestore, cloneStagingName } from '../../src/lib/server/backups/restore-script'; import { SWAP_NEW, SWAP_ARTIFACTS } from '../../src/lib/server/backups/swap'; describe('buildNewLocationRestore', () => { it('restores the requested includes into the fresh target', () => { const a = buildNewLocationRestore('snap', '/restore/here', ['/volumes/data'], []); expect(a).toEqual(['restore', '--json', 'snap', '--target', '/restore/here', '--include', '/volumes/data']); }); it('restores the whole snapshot when no includes given, and appends flags', () => { const a = buildNewLocationRestore('snap', '/out', [], ['--verbose']); expect(a).toEqual(['restore', '--json', 'snap', '--target', '/out', '--verbose']); }); }); describe('buildInPlaceRestore — structure', () => { it('runs under set -e, restores into the staging dir, and includes the swap', () => { const script = buildInPlaceRestore('snap', ['/volumes/data'], []); expect(script).toMatch(/^set -e;/); expect(script).toContain(SWAP_NEW); expect(script).toContain("restic 'restore'"); expect(script).toContain("'--target'"); }); it('references every swap artifact (so the swap + backup exclude stay in sync)', () => { const script = buildInPlaceRestore('snap', ['/volumes/data'], []); for (const a of SWAP_ARTIFACTS) expect(script).toContain(a); }); it('DIR branch clears the staging dir BEFORE restic (no stale-merge from a killed prior restore)', () => { // restic restore has no --delete: without wiping the staging dir first, a marker-less // leftover from a previously-killed restore would keep files not in this snapshot and the // swap would commit them as "restored". The rm -rf must precede the restic restore. const script = buildInPlaceRestore('snap', ['/volumes/data'], []); const rmIdx = script.indexOf(`rm -rf '${SWAP_NEW}'`) >= 0 ? script.indexOf(`rm -rf '${SWAP_NEW}'`) : script.search(new RegExp(`rm -rf '[^']*${SWAP_NEW}[^']*'`)); const resticIdx = script.indexOf("restic 'restore'"); expect(rmIdx).toBeGreaterThanOrEqual(0); expect(resticIdx).toBeGreaterThanOrEqual(0); expect(rmIdx).toBeLessThan(resticIdx); // staging wiped before restore }); it('single-file branch guards existence BEFORE overwriting the live file (never truncate on empty restore)', () => { // `cat X > live` truncates `live` before cat runs; if the restored file is missing // (restic exit 0 but nothing extracted) the live user file would be zeroed then the // command fails - destroying data. The file branch must check `[ -f ]` // first and refuse, leaving the live file untouched. const script = buildInPlaceRestore('snap', ['/volumes/etc_app_cfg.yaml'], []); expect(script).toMatch(/if \[ -f [^\]]+ \]; then cat .+ > /); expect(script).toContain('refusing to truncate live file'); }); }); describe('buildCloneRestore — structure', () => { it('runs under set -e, extracts into an isolated staging dir (never rm -rf a user dir at the mount)', () => { const script = buildCloneRestore('snap', ['/volumes/data'], []); expect(script).toMatch(/^set -e;/); expect(script).toContain("restic 'restore'"); // restic targets the staging dir INSIDE the mount, not the mount root itself. expect(script).toContain("'--target' '/volumes/data/.dockhand-clone-snap'"); expect(script).toContain("'--include' '/volumes/data'"); // The ONLY rm is of our own staging dir — NEVER '/volumes' (which could // be a user's own directory at a bind destination). expect(script).toContain("rm -rf '/volumes/data/.dockhand-clone-snap'"); expect(script).not.toContain("rm -rf '/volumes/data/volumes'"); // Not the in-place swap machinery. expect(script).not.toContain(SWAP_NEW); for (const a of SWAP_ARTIFACTS) expect(script).not.toContain(a); }); it('handles multiple mount roots', () => { const script = buildCloneRestore('snap', ['/volumes/a', '/volumes/b'], ['--verbose']); expect(script).toContain("'--target' '/volumes/a/.dockhand-clone-snap'"); expect(script).toContain("'--target' '/volumes/b/.dockhand-clone-snap'"); expect(script).toContain("'--verbose'"); }); it('NEVER passes restic --delete — a restore ADDS to the destination, never wipes it', () => { // Data-safety guard: when a bind destination points at a host path that // already has the user's files, restic must overlay the snapshot's files on // top, not delete anything that isn't in the snapshot. `restic restore` // without --delete does exactly that; assert we never add the flag. const script = buildCloneRestore('snap', ['/volumes/data'], []); expect(script).not.toContain('--delete'); }); it('gives DIFFERENT snapshots DIFFERENT staging dirs (concurrent clones to one path never collide)', () => { // Two clones into the same host path from different snapshots would otherwise // share one staging dir, and one's cleanup would wipe the other's files. const a = cloneStagingName('aaaaaaaaaaaa1111'); const b = cloneStagingName('bbbbbbbbbbbb2222'); expect(a).not.toBe(b); expect(a.startsWith('.dockhand-clone-')).toBe(true); // It's inert to path traversal / shell metachars in the snapshot id. expect(cloneStagingName('../../etc/passwd')).toBe('.dockhand-clone-etcpasswd'); }); }); // A single-FILE bind (./cfg.yaml:/x) mounts file-on-file, so the live volume root is a // FILE and an ACTIVE mount point — it can be written through but NOT renamed over (`mv` // -> "Device or resource busy"). The old script mkdir'd a staging dir INSIDE it (`mkdir // : not a directory`) and the restore FAILED (found by the data-in-stack-folder // matrix). The builder now branches at runtime: a file restores into a sibling temp and // overwrites the live file's CONTENT via `cat` (writes through the mount). describe('buildInPlaceRestore — single-file bind', () => { it('emits a file branch that does not mkdir a staging dir inside the live path', () => { const script = buildInPlaceRestore('snap', ['/volumes/etc_cfg.yaml'], []); // Runtime dir/file branch. expect(script).toContain("if [ -d '/volumes/etc_cfg.yaml' ]; then"); // The file branch restores into a SIBLING temp under /volumes, never inside the file. expect(script).toContain("'--target' '/volumes/.dockhand-restore-file-etc_cfg.yaml'"); // And commits by writing content THROUGH the mount (cat), NOT renaming (which is busy). expect(script).toContain("cat '/volumes/.dockhand-restore-file-etc_cfg.yaml/volumes/etc_cfg.yaml' > '/volumes/etc_cfg.yaml'"); expect(script).not.toContain("mv -f '/volumes/.dockhand-restore-file"); }); it('real-fs round-trip: replaces a live FILE with the restored one, no leftovers', () => { const { execFileSync } = require('child_process'); const { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } = require('fs'); const { tmpdir } = require('os'); const { join } = require('path'); // Sandbox: a /volumes dir with the live file to be replaced. const root = mkdtempSync(join(tmpdir(), 'filebind-')); const volumes = join(root, 'volumes'); mkdirSync(volumes, { recursive: true }); const live = join(volumes, 'cfg'); writeFileSync(live, 'LIVE-OLD\n'); // Build for the sandbox path, then stub restic. Real restic recreates the include's // FULL path under --target, i.e. writes to . Model that faithfully so // the script's `cat > ` finds the file where it expects it. const script = buildInPlaceRestore('snap', [live], []); const stagingTarget = `${volumes}/.dockhand-restore-file-cfg`; const mockRestic = `mkdir -p "$(dirname '${stagingTarget}${live}')"; printf 'RESTORED-NEW' > '${stagingTarget}${live}'`; const runnable = script.replace(/restic 'restore'[^;]*/g, mockRestic); const file = join(root, 'run.sh'); writeFileSync(file, runnable); try { execFileSync('sh', [file], { stdio: 'pipe' }); // The live file now holds the restored content... expect(readFileSync(live, 'utf8')).toBe('RESTORED-NEW'); // ...and nothing but `cfg` remains in /volumes (temp dir cleaned up). expect(readdirSync(volumes)).toEqual(['cfg']); } finally { rmSync(root, { recursive: true, force: true }); } }); }); describe('buildCloneRestore — real-fs round-trip (models restic writing to /volumes/)', () => { const { execFileSync } = require('child_process'); const { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync } = require('fs'); const { tmpdir } = require('os'); const { join } = require('path'); it('flattens the restic output up to the mount root (no swap, fresh volume)', () => { // The mount root in production is `/volumes/` (the helper bind dest). // Model a sandbox whose mount root ends in /volumes/data. const root = mkdtempSync(join(tmpdir(), 'clone-')); const mount = join(root, 'volumes', 'data'); mkdirSync(mount, { recursive: true }); const script = buildCloneRestore('snap', [mount], []); // Model restic `restore --target /.dockhand-clone-snap --include `: // it recreates the include path under the target, i.e. writes to // . The script moves /* up to / then // removes the staging dir. const staging = `${mount}/.dockhand-clone-snap`; const mockRestic = `mkdir -p '${staging}${mount}'; printf DATA > '${staging}${mount}/file.txt'`; const runnable = script.replace(/restic 'restore'[^;]*/, mockRestic); const file = join(root, 'run.sh'); writeFileSync(file, runnable); try { execFileSync('sh', [file], { stdio: 'pipe' }); const names = readdirSync(mount); expect(names).toEqual(['file.txt']); expect(readFileSync(join(mount, 'file.txt'), 'utf8')).toBe('DATA'); } finally { rmSync(root, { recursive: true, force: true }); } }); it('KEEPS the user\'s pre-existing files at the destination — restore only adds', () => { // The exact data-safety property the user cares about: restoring a bind into a // host path that already holds the user's files must PRESERVE them and add the // snapshot's files alongside. Seed the mount root with a user file, run the // clone restore (modelling restic writing one new file), assert both survive. const root = mkdtempSync(join(tmpdir(), 'clone-keep-')); const mount = join(root, 'volumes', 'data'); mkdirSync(mount, { recursive: true }); // The user's pre-existing data on the host path. writeFileSync(join(mount, 'user-keep.txt'), 'USER_DATA'); mkdirSync(join(mount, 'user-dir'), { recursive: true }); writeFileSync(join(mount, 'user-dir', 'nested.txt'), 'USER_NESTED'); // THE edge case that motivated the staging-dir rewrite: the user has their OWN // directory literally named `volumes` at the bind destination. The old code did // `rm -rf /volumes` and would have wiped it. It must survive. mkdirSync(join(mount, 'volumes'), { recursive: true }); writeFileSync(join(mount, 'volumes', 'mine.txt'), 'USER_VOLUMES_DIR'); const script = buildCloneRestore('snap', [mount], []); // restic (no --delete) writes the snapshot's file under . const staging = `${mount}/.dockhand-clone-snap`; const mockRestic = `mkdir -p '${staging}${mount}'; printf FROM_BACKUP > '${staging}${mount}/restored.txt'`; const runnable = script.replace(/restic 'restore'[^;]*/, mockRestic); const file = join(root, 'run.sh'); writeFileSync(file, runnable); try { execFileSync('sh', [file], { stdio: 'pipe' }); const names = readdirSync(mount).sort(); // The user's files AND their `volumes/` dir AND the restored file all // present; the staging dir is gone. Nothing of the user's was wiped. expect(names).toEqual(['restored.txt', 'user-dir', 'user-keep.txt', 'volumes']); expect(readFileSync(join(mount, 'user-keep.txt'), 'utf8')).toBe('USER_DATA'); expect(readFileSync(join(mount, 'user-dir', 'nested.txt'), 'utf8')).toBe('USER_NESTED'); expect(readFileSync(join(mount, 'volumes', 'mine.txt'), 'utf8')).toBe('USER_VOLUMES_DIR'); expect(readFileSync(join(mount, 'restored.txt'), 'utf8')).toBe('FROM_BACKUP'); } finally { rmSync(root, { recursive: true, force: true }); } }); it('MERGES the restored tree into a pre-existing NON-EMPTY dir (the mv "Directory not empty" bug)', () => { // Cross-env restore where the destination already has the same subdirectory // with contents (e.g. Home Assistant `config/blueprints`). Plain `mv` refuses // with "can't rename 'blueprints': Directory not empty"; cp -a must merge: // keep the user's existing file, add the restored one, overwrite collisions. const root = mkdtempSync(join(tmpdir(), 'clone-merge-')); const mount = join(root, 'volumes', 'config'); mkdirSync(join(mount, 'blueprints'), { recursive: true }); writeFileSync(join(mount, 'blueprints', 'existing.yaml'), 'OLD'); writeFileSync(join(mount, 'blueprints', 'shared.yaml'), 'OLD_SHARED'); const script = buildCloneRestore('snap', [mount], []); const staging = `${mount}/.dockhand-clone-snap`; // restic restores a blueprints/ dir with a NEW file + an OVERWRITE of shared.yaml. const mockRestic = `mkdir -p '${staging}${mount}/blueprints'; ` + `printf NEW > '${staging}${mount}/blueprints/restored.yaml'; ` + `printf NEW_SHARED > '${staging}${mount}/blueprints/shared.yaml'`; const runnable = script.replace(/restic 'restore'[^;]*/, mockRestic); const file = join(root, 'run.sh'); writeFileSync(file, runnable); try { execFileSync('sh', [file], { stdio: 'pipe' }); // must NOT throw const bp = readdirSync(join(mount, 'blueprints')).sort(); expect(bp).toEqual(['existing.yaml', 'restored.yaml', 'shared.yaml']); expect(readFileSync(join(mount, 'blueprints', 'existing.yaml'), 'utf8')).toBe('OLD'); // kept expect(readFileSync(join(mount, 'blueprints', 'restored.yaml'), 'utf8')).toBe('NEW'); // added expect(readFileSync(join(mount, 'blueprints', 'shared.yaml'), 'utf8')).toBe('NEW_SHARED'); // overwritten } finally { rmSync(root, { recursive: true, force: true }); } }); }); describe('buildInPlaceRestore — real-fs round-trip (models restic writing to staging/volumes/)', () => { const { execFileSync } = require('child_process'); const { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync } = require('fs'); const { tmpdir } = require('os'); const { join } = require('path'); it('flattens the restic output and swaps it into the live root', () => { // The live root is always a `/volumes/` path in production; the // script's scaffold cleanup (`rm -rf /volumes`) depends on that. // Build a sandbox whose live dir ends in /volumes/data and drive the script // with `live` = that absolute path. const root = mkdtempSync(join(tmpdir(), 'restore-')); const live = join(root, 'volumes', 'data'); mkdirSync(live, { recursive: true }); writeFileSync(join(live, 'old.txt'), 'OLD'); const script = buildInPlaceRestore('snap', [live], []); const staging = `${live}/${SWAP_NEW}`; // Model restic `restore --target --include `: it writes to // (i.e. //volumes/data). The script flattens // /* up to and removes the /volumes // scaffold. Reproduce restic's output layout exactly. const mockRestic = `mkdir -p '${staging}${live}'; printf NEW > '${staging}${live}/restored.txt'`; const runnable = script.replace(/restic 'restore'[^;]*/, mockRestic); const file = join(root, 'run.sh'); writeFileSync(file, runnable); try { execFileSync('sh', [file], { stdio: 'pipe' }); const names = readdirSync(live).filter((n: string) => !(SWAP_ARTIFACTS as readonly string[]).includes(n)); expect(names).toEqual(['restored.txt']); expect(readFileSync(join(live, 'restored.txt'), 'utf8')).toBe('NEW'); } finally { rmSync(root, { recursive: true, force: true }); } }); });