/** * Tests for `mosaic restore` (#791 PR2 Task 12). * * The durable pre-update snapshot (install.sh: make_durable_snapshot) writes the * operator-owned surface to $XDG_STATE_HOME/mosaic/backups/pre-update-/ with * 0700 dirs / 0600 files. `mosaic restore` is the recovery counterpart: * • --list (default) enumerate snapshots by timestamp — dry-run, never mutates. * • --from restore that snapshot over MOSAIC_HOME, confirmation-gated. * It reports counts and relative paths ONLY — a snapshot may contain secrets * (credentials.json), so no file content is ever printed (secrev invariant). */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, statSync, lstatSync, symlinkSync, } from 'node:fs'; import { join } from 'node:path'; import { tmpdir, homedir } from 'node:os'; import { Command } from 'commander'; import { resolveBackupRoot, listSnapshots, resolveSnapshotDir, planRestore, applyRestore, runRestore, registerRestoreCommand, } from './restore.js'; const SECRET = 'SUPER-SECRET-TOKEN-do-not-log-restore'; function seedSnapshot(root: string, ts: string, files: Record): string { const dir = join(root, `pre-update-${ts}`); for (const [rel, content] of Object.entries(files)) { const abs = join(dir, rel); mkdirSync(join(abs, '..'), { recursive: true }); writeFileSync(abs, content); } return dir; } describe('mosaic restore (#791 PR2)', () => { let tmp: string; let backups: string; beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'mosaic-restore-')); backups = join(tmp, 'state', 'mosaic', 'backups'); mkdirSync(backups, { recursive: true }); vi.clearAllMocks(); }); afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); describe('resolveBackupRoot', () => { it('honors XDG_STATE_HOME', () => { expect(resolveBackupRoot({ XDG_STATE_HOME: '/x/state' })).toBe('/x/state/mosaic/backups'); }); it('falls back to ~/.local/state', () => { expect(resolveBackupRoot({})).toBe(join(homedir(), '.local', 'state', 'mosaic', 'backups')); }); }); describe('listSnapshots', () => { it('returns [] when the backup root does not exist', () => { expect(listSnapshots(join(tmp, 'nope'))).toEqual([]); }); it('lists pre-update snapshots newest-first with file counts, ignoring other dirs', () => { seedSnapshot(backups, '20240101T000000Z', { 'SOUL.md': 'a' }); seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'b', 'agents/x.conf': 'c' }); mkdirSync(join(backups, 'unrelated-dir'), { recursive: true }); const snaps = listSnapshots(backups); expect(snaps.map((s) => s.timestamp)).toEqual(['20260101T000000Z', '20240101T000000Z']); expect(snaps[0]!.fileCount).toBe(2); expect(snaps[1]!.fileCount).toBe(1); }); }); describe('resolveSnapshotDir', () => { it('resolves by bare timestamp and by full pre-update- name', () => { const dir = seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'a' }); expect(resolveSnapshotDir(backups, '20260101T000000Z')).toBe(dir); expect(resolveSnapshotDir(backups, 'pre-update-20260101T000000Z')).toBe(dir); }); it('returns undefined for an unknown timestamp', () => { expect(resolveSnapshotDir(backups, '19990101T000000Z')).toBeUndefined(); }); }); describe('planRestore', () => { it('walks nested dirs and returns every relative file path', () => { const dir = seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'a', 'agents/x.conf': 'b', 'tools/_lib/credentials.json': 'c', }); expect(planRestore(dir).sort()).toEqual( ['SOUL.md', 'agents/x.conf', 'tools/_lib/credentials.json'].sort(), ); }); }); describe('applyRestore', () => { it('restores byte-exact content, creates parent dirs, and sets 0600', () => { const dir = seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'original-soul', 'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`, }); const home = join(tmp, 'home'); mkdirSync(home, { recursive: true }); // A diverged operator file that restore must overwrite. writeFileSync(join(home, 'SOUL.md'), 'CORRUPTED'); const n = applyRestore(dir, home, planRestore(dir)); expect(n).toBe(2); expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('original-soul'); expect(readFileSync(join(home, 'tools/_lib/credentials.json'), 'utf8')).toBe( `TOKEN=${SECRET}\n`, ); expect(statSync(join(home, 'SOUL.md')).mode & 0o777).toBe(0o600); expect(statSync(join(home, 'tools/_lib/credentials.json')).mode & 0o777).toBe(0o600); }); }); describe('runRestore', () => { it('--list prints timestamps and counts, mutating nothing', async () => { seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'a', 'agents/x.conf': 'b' }); const home = join(tmp, 'home'); mkdirSync(home, { recursive: true }); const log = vi.spyOn(console, 'log').mockImplementation(() => {}); const code = await runRestore({ list: true, mosaicHome: home, env: { XDG_STATE_HOME: join(tmp, 'state') }, }); expect(code).toBe(0); const out = log.mock.calls.flat().join('\n'); expect(out).toContain('20260101T000000Z'); expect(out).toMatch(/2\b/); // the file count is surfaced log.mockRestore(); }); it('--from restores the snapshot over MOSAIC_HOME byte-exact (yes bypasses prompt)', async () => { seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'restored-soul', 'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`, }); const home = join(tmp, 'home'); mkdirSync(home, { recursive: true }); writeFileSync(join(home, 'SOUL.md'), 'STALE'); const code = await runRestore({ from: '20260101T000000Z', yes: true, mosaicHome: home, env: { XDG_STATE_HOME: join(tmp, 'state') }, }); expect(code).toBe(0); expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('restored-soul'); expect(readFileSync(join(home, 'tools/_lib/credentials.json'), 'utf8')).toBe( `TOKEN=${SECRET}\n`, ); }); it('--from with an unknown timestamp fails without mutating', async () => { const home = join(tmp, 'home'); mkdirSync(home, { recursive: true }); writeFileSync(join(home, 'SOUL.md'), 'KEEP'); const err = vi.spyOn(console, 'error').mockImplementation(() => {}); const code = await runRestore({ from: '19990101T000000Z', yes: true, mosaicHome: home, env: { XDG_STATE_HOME: join(tmp, 'state') }, }); expect(code).toBe(1); expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('KEEP'); err.mockRestore(); }); it('--dry-run with --from reports the plan but mutates nothing', async () => { seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'snap' }); const home = join(tmp, 'home'); mkdirSync(home, { recursive: true }); writeFileSync(join(home, 'SOUL.md'), 'UNCHANGED'); const log = vi.spyOn(console, 'log').mockImplementation(() => {}); const code = await runRestore({ from: '20260101T000000Z', dryRun: true, mosaicHome: home, env: { XDG_STATE_HOME: join(tmp, 'state') }, }); expect(code).toBe(0); expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('UNCHANGED'); log.mockRestore(); }); it('--from prompts and applies the restore when the operator confirms', async () => { seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'confirmed-soul' }); const home = join(tmp, 'home'); mkdirSync(home, { recursive: true }); writeFileSync(join(home, 'SOUL.md'), 'STALE'); vi.spyOn(console, 'log').mockImplementation(() => {}); const confirm = vi.fn().mockResolvedValue(true); const code = await runRestore({ from: '20260101T000000Z', mosaicHome: home, env: { XDG_STATE_HOME: join(tmp, 'state') }, confirm, }); expect(code).toBe(0); expect(confirm).toHaveBeenCalledOnce(); expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('confirmed-soul'); }); it('--from aborts without mutating when the operator declines', async () => { seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'snap' }); const home = join(tmp, 'home'); mkdirSync(home, { recursive: true }); writeFileSync(join(home, 'SOUL.md'), 'KEEP'); vi.spyOn(console, 'log').mockImplementation(() => {}); const confirm = vi.fn().mockResolvedValue(false); const code = await runRestore({ from: '20260101T000000Z', mosaicHome: home, env: { XDG_STATE_HOME: join(tmp, 'state') }, confirm, }); expect(code).toBe(0); expect(confirm).toHaveBeenCalledOnce(); expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('KEEP'); }); it('MOSAIC_ASSUME_YES=1 bypasses the confirmation prompt', async () => { seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'env-yes' }); const home = join(tmp, 'home'); mkdirSync(home, { recursive: true }); writeFileSync(join(home, 'SOUL.md'), 'STALE'); vi.spyOn(console, 'log').mockImplementation(() => {}); const confirm = vi.fn().mockResolvedValue(false); const code = await runRestore({ from: '20260101T000000Z', mosaicHome: home, env: { XDG_STATE_HOME: join(tmp, 'state'), MOSAIC_ASSUME_YES: '1' }, confirm, }); expect(code).toBe(0); expect(confirm).not.toHaveBeenCalled(); expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('env-yes'); }); it('--list reports gracefully when no snapshots exist', async () => { const home = join(tmp, 'home'); mkdirSync(home, { recursive: true }); const log = vi.spyOn(console, 'log').mockImplementation(() => {}); const code = await runRestore({ list: true, mosaicHome: home, env: { XDG_STATE_HOME: join(tmp, 'empty-state') }, }); expect(code).toBe(0); expect(log.mock.calls.flat().join('\n')).toMatch(/No pre-update snapshots/); }); it('never prints a secret value found inside a backed-up file', async () => { seedSnapshot(backups, '20260101T000000Z', { 'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`, }); const home = join(tmp, 'home'); mkdirSync(home, { recursive: true }); const log = vi.spyOn(console, 'log').mockImplementation(() => {}); const err = vi.spyOn(console, 'error').mockImplementation(() => {}); await runRestore({ list: true, mosaicHome: home, env: { XDG_STATE_HOME: join(tmp, 'state') }, }); await runRestore({ from: '20260101T000000Z', yes: true, mosaicHome: home, env: { XDG_STATE_HOME: join(tmp, 'state') }, }); const all = [...log.mock.calls, ...err.mock.calls].flat().join('\n'); expect(all).not.toContain(SECRET); log.mockRestore(); err.mockRestore(); }); }); // Regression coverage for the codex code+security review of PR2 (#791): // CWE-22 traversal via --from, and CWE-59 symlink write-through in applyRestore. describe('security hardening', () => { it.each([ '../../etc', 'pre-update-/../../tmp/poison', 'pre-update-../evil', '20260101T000000Z/../../../tmp', 'not-a-timestamp', '2026-01-01', ])('resolveSnapshotDir rejects traversal / malformed selector %j', (bad) => { // Even if a matching directory exists on disk, a non-timestamp selector // must not resolve — the only accepted shape is <8>T<6>Z[-n]. expect(resolveSnapshotDir(backups, bad)).toBeUndefined(); }); it('runRestore --from a traversal selector fails closed without copying', async () => { // Plant a real dir one level ABOVE the backup root. The naive resolver // `join(root, from)` with `from='../poison'` would reach it (backups is // .../mosaic/backups, so `../poison` == .../mosaic/poison) and import it. const outside = join(tmp, 'state', 'mosaic', 'poison'); mkdirSync(outside, { recursive: true }); writeFileSync(join(outside, 'x'), 'attacker'); const home = join(tmp, 'home'); mkdirSync(home, { recursive: true }); const err = vi.spyOn(console, 'error').mockImplementation(() => {}); const code = await runRestore({ from: '../poison', yes: true, mosaicHome: home, env: { XDG_STATE_HOME: join(tmp, 'state') }, }); expect(code).toBe(1); expect(statSync(home).isDirectory()).toBe(true); // Nothing from `outside` was imported. expect(() => statSync(join(home, 'x'))).toThrow(); err.mockRestore(); }); it('applyRestore refuses to write a secret through a symlinked leaf (CWE-59)', () => { const dir = seedSnapshot(backups, '20260101T000000Z', { 'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`, }); const home = join(tmp, 'home'); mkdirSync(join(home, 'tools', '_lib'), { recursive: true }); // Attacker points the operator credentials file at a file they can read. const exfil = join(tmp, 'exfil-target'); writeFileSync(exfil, 'original-attacker-content'); symlinkSync(exfil, join(home, 'tools', '_lib', 'credentials.json')); expect(() => applyRestore(dir, home, planRestore(dir))).toThrow(); // The secret was NOT written through the link into the attacker's file. expect(readFileSync(exfil, 'utf8')).toBe('original-attacker-content'); }); it('applyRestore refuses to write through a symlinked ancestor (CWE-59)', () => { const dir = seedSnapshot(backups, '20260101T000000Z', { 'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`, }); const home = join(tmp, 'home'); mkdirSync(join(home, 'tools'), { recursive: true }); // Attacker replaces the `tools/_lib` ancestor with a symlink out of the root. const exfilDir = join(tmp, 'exfil-dir'); mkdirSync(exfilDir, { recursive: true }); symlinkSync(exfilDir, join(home, 'tools', '_lib')); expect(() => applyRestore(dir, home, planRestore(dir))).toThrow(); // Nothing was written into the attacker-controlled directory. expect(() => statSync(join(exfilDir, 'credentials.json'))).toThrow(); }); it('runRestore surfaces a symlink violation as exit 1 without leaking the secret', async () => { seedSnapshot(backups, '20260101T000000Z', { 'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`, }); const home = join(tmp, 'home'); mkdirSync(join(home, 'tools', '_lib'), { recursive: true }); const exfil = join(tmp, 'exfil-target'); writeFileSync(exfil, 'attacker'); symlinkSync(exfil, join(home, 'tools', '_lib', 'credentials.json')); const log = vi.spyOn(console, 'log').mockImplementation(() => {}); const err = vi.spyOn(console, 'error').mockImplementation(() => {}); const code = await runRestore({ from: '20260101T000000Z', yes: true, mosaicHome: home, env: { XDG_STATE_HOME: join(tmp, 'state') }, }); expect(code).toBe(1); expect(readFileSync(exfil, 'utf8')).toBe('attacker'); const all = [...log.mock.calls, ...err.mock.calls].flat().join('\n'); expect(all).not.toContain(SECRET); log.mockRestore(); err.mockRestore(); }); it('applyRestore replaces a diverged regular file in place with 0600 (not a symlink)', () => { const dir = seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'restored' }); const home = join(tmp, 'home'); mkdirSync(home, { recursive: true }); writeFileSync(join(home, 'SOUL.md'), 'stale'); const n = applyRestore(dir, home, planRestore(dir)); expect(n).toBe(1); expect(lstatSync(join(home, 'SOUL.md')).isSymbolicLink()).toBe(false); expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('restored'); expect(statSync(join(home, 'SOUL.md')).mode & 0o777).toBe(0o600); }); }); describe('registerRestoreCommand', () => { it('registers `restore` with the expected flags', () => { const program = new Command(); program.exitOverride(); registerRestoreCommand(program); const cmd = program.commands.find((c) => c.name() === 'restore'); expect(cmd).toBeDefined(); const longs = cmd!.options.map((o) => o.long); expect(longs).toEqual( expect.arrayContaining(['--list', '--from', '--dry-run', '--yes', '--mosaic-home']), ); }); it('runs the list action end-to-end via the parsed command', async () => { seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'a' }); const prevXdg = process.env['XDG_STATE_HOME']; process.env['XDG_STATE_HOME'] = join(tmp, 'state'); const log = vi.spyOn(console, 'log').mockImplementation(() => {}); try { const program = new Command(); program.exitOverride(); registerRestoreCommand(program); await program.parseAsync(['restore', '--list', '--mosaic-home', join(tmp, 'home')], { from: 'user', }); expect(log.mock.calls.flat().join('\n')).toContain('20260101T000000Z'); } finally { log.mockRestore(); if (prevXdg === undefined) delete process.env['XDG_STATE_HOME']; else process.env['XDG_STATE_HOME'] = prevXdg; } }); }); });