diff --git a/scripts/odysseus-backup b/scripts/odysseus-backup index 28f187f..b0f3120 100755 --- a/scripts/odysseus-backup +++ b/scripts/odysseus-backup @@ -56,6 +56,16 @@ def _sqlite_safe_copy(src: Path, dst: Path) -> None: dst.write_bytes(src.read_bytes()) +def _reject_output_inside_data(out_path: Path) -> None: + try: + resolved = out_path.resolve() + data_root = _DATA_DIR.resolve() + resolved.relative_to(data_root) + except ValueError: + return + fail("backup output path must be outside data/") + + def cmd_snapshot(args): """Write a tar.gz of the entire data/ directory. @@ -68,6 +78,7 @@ def cmd_snapshot(args): out_path = Path(args.out) if args.out else ( _BACKUP_DIR / f"odysseus-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.tar.gz" ) + _reject_output_inside_data(out_path) out_path.parent.mkdir(parents=True, exist_ok=True) sqlite_dbs = [p for p in _DATA_DIR.rglob("*.db") if p.is_file() and not p.is_symlink()] diff --git a/tests/test_backup_cli_security.py b/tests/test_backup_cli_security.py index b10aee3..e192b79 100644 --- a/tests/test_backup_cli_security.py +++ b/tests/test_backup_cli_security.py @@ -30,6 +30,17 @@ def _verify_args(path: Path): return SimpleNamespace(path=str(path), pretty=False) +def test_snapshot_rejects_output_inside_data_dir(tmp_path, monkeypatch): + backup = _load_backup_cli() + repo = tmp_path / "repo" + data = repo / "data" + data.mkdir(parents=True) + _patch_repo(backup, monkeypatch, repo) + + with pytest.raises(SystemExit): + backup._reject_output_inside_data(data / "self.tar.gz") + + def test_restore_rejects_symlink_escape(tmp_path, monkeypatch): backup = _load_backup_cli() repo = tmp_path / "repo"