| import importlib.machinery |
| import importlib.util |
| import io |
| import tarfile |
| from pathlib import Path |
| from types import SimpleNamespace |
|
|
| import pytest |
|
|
|
|
| def _load_backup_cli(): |
| path = Path(__file__).resolve().parent.parent / "scripts" / "odysseus-backup" |
| loader = importlib.machinery.SourceFileLoader("odysseus_backup_under_test", str(path)) |
| spec = importlib.util.spec_from_loader(loader.name, loader) |
| module = importlib.util.module_from_spec(spec) |
| loader.exec_module(module) |
| return module |
|
|
|
|
| def _patch_repo(module, monkeypatch, root: Path): |
| monkeypatch.setattr(module, "_REPO_ROOT", root) |
| monkeypatch.setattr(module, "_DATA_DIR", root / "data") |
|
|
|
|
| def _restore_args(path: Path): |
| return SimpleNamespace(path=str(path), yes=True, pretty=False) |
|
|
|
|
| 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" |
| data = repo / "data" |
| outside = tmp_path / "outside" |
| data.mkdir(parents=True) |
| outside.mkdir() |
| (data / "keep.txt").write_text("still here", encoding="utf-8") |
| _patch_repo(backup, monkeypatch, repo) |
|
|
| tar_path = tmp_path / "malicious.tar.gz" |
| with tarfile.open(tar_path, "w:gz") as tar: |
| data_dir = tarfile.TarInfo("data") |
| data_dir.type = tarfile.DIRTYPE |
| tar.addfile(data_dir) |
|
|
| link = tarfile.TarInfo("data/link") |
| link.type = tarfile.SYMTYPE |
| link.linkname = str(outside) |
| tar.addfile(link) |
|
|
| payload = b"escaped" |
| escaped = tarfile.TarInfo("data/link/pwned.txt") |
| escaped.size = len(payload) |
| tar.addfile(escaped, io.BytesIO(payload)) |
|
|
| with pytest.raises(SystemExit): |
| backup.cmd_restore(_restore_args(tar_path)) |
|
|
| assert not (outside / "pwned.txt").exists() |
| assert (data / "keep.txt").read_text(encoding="utf-8") == "still here" |
|
|
|
|
| def test_verify_rejects_symlink_escape(tmp_path): |
| backup = _load_backup_cli() |
|
|
| tar_path = tmp_path / "malicious.tar.gz" |
| with tarfile.open(tar_path, "w:gz") as tar: |
| link = tarfile.TarInfo("data/link") |
| link.type = tarfile.SYMTYPE |
| link.linkname = "/tmp" |
| tar.addfile(link) |
|
|
| with pytest.raises(SystemExit): |
| backup.cmd_verify(_verify_args(tar_path)) |
|
|
|
|
| def test_restore_rejects_hardlink_entries(tmp_path, monkeypatch): |
| backup = _load_backup_cli() |
| repo = tmp_path / "repo" |
| (repo / "data").mkdir(parents=True) |
| _patch_repo(backup, monkeypatch, repo) |
|
|
| tar_path = tmp_path / "hardlink.tar.gz" |
| with tarfile.open(tar_path, "w:gz") as tar: |
| link = tarfile.TarInfo("data/hardlink") |
| link.type = tarfile.LNKTYPE |
| link.linkname = "../outside.txt" |
| tar.addfile(link) |
|
|
| with pytest.raises(SystemExit): |
| backup.cmd_restore(_restore_args(tar_path)) |
|
|
|
|
| def test_restore_extracts_regular_files_without_extractall(tmp_path, monkeypatch): |
| backup = _load_backup_cli() |
| repo = tmp_path / "repo" |
| data = repo / "data" |
| data.mkdir(parents=True) |
| (data / "old.txt").write_text("old", encoding="utf-8") |
| _patch_repo(backup, monkeypatch, repo) |
|
|
| tar_path = tmp_path / "valid.tar.gz" |
| with tarfile.open(tar_path, "w:gz") as tar: |
| folder = tarfile.TarInfo("data/nested") |
| folder.type = tarfile.DIRTYPE |
| tar.addfile(folder) |
|
|
| payload = b"new" |
| item = tarfile.TarInfo("data/nested/new.txt") |
| item.size = len(payload) |
| tar.addfile(item, io.BytesIO(payload)) |
|
|
| backup.cmd_restore(_restore_args(tar_path)) |
|
|
| assert (repo / "data" / "nested" / "new.txt").read_text(encoding="utf-8") == "new" |
| assert not (repo / "data" / "old.txt").exists() |
| assert list(repo.glob("data.before-restore-*")) |
|
|