mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-11 12:27:13 +00:00
167 lines
5.0 KiB
Python
167 lines
5.0 KiB
Python
import io
|
|
import tarfile
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from tests.helpers.cli_loader import load_script
|
|
|
|
|
|
def _load_backup_cli():
|
|
return load_script("odysseus-backup")
|
|
|
|
|
|
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_backup_entry_skips_files_that_disappear():
|
|
backup = _load_backup_cli()
|
|
|
|
class Vanished:
|
|
name = "gone.tar.gz"
|
|
|
|
def is_file(self):
|
|
return True
|
|
|
|
def stat(self):
|
|
raise FileNotFoundError("gone")
|
|
|
|
def __str__(self):
|
|
return "backups/gone.tar.gz"
|
|
|
|
assert backup._backup_entry(Vanished()) is None
|
|
|
|
|
|
def test_backup_list_sorts_by_captured_mtime(monkeypatch):
|
|
backup = _load_backup_cli()
|
|
first = SimpleNamespace(name="older.tar.gz")
|
|
second = SimpleNamespace(name="newer.tar.gz")
|
|
monkeypatch.setattr(backup, "_BACKUP_DIR", SimpleNamespace(
|
|
is_dir=lambda: True,
|
|
iterdir=lambda: [first, second],
|
|
))
|
|
monkeypatch.setattr(backup, "_backup_entry", lambda p: {
|
|
"name": p.name,
|
|
"modified": "2026-10-25T01:45:00" if p is first else "2026-10-25T01:15:00",
|
|
"_mtime": 100 if p is first else 200,
|
|
})
|
|
seen = []
|
|
monkeypatch.setattr(backup, "emit", lambda payload, args: seen.append(payload))
|
|
|
|
backup.cmd_list(SimpleNamespace(pretty=False))
|
|
|
|
assert [entry["name"] for entry in seen[0]] == ["newer.tar.gz", "older.tar.gz"]
|
|
assert all("_mtime" not in entry for entry in seen[0])
|
|
|
|
|
|
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-*"))
|