467 lines
17 KiB
TypeScript
467 lines
17 KiB
TypeScript
/**
|
|
* 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-<ts>/ with
|
|
* 0700 dirs / 0600 files. `mosaic restore` is the recovery counterpart:
|
|
* • --list (default) enumerate snapshots by timestamp — dry-run, never mutates.
|
|
* • --from <ts> 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, string>): 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-<ts> 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;
|
|
}
|
|
});
|
|
});
|
|
});
|