* Validate topology and destructive path boundaries - Canonicalize sources/backups_dir and reject overlap in either direction - Reject duplicate sources and destination basename collisions - Reject non-normalized relative paths in nest_hardlink - Give each backup repository a persistent identity, recorded in every snapshot's completion manifest - Verify repo identity, directory type (no symlinks), containment, and device before any recursive snapshot/staging-dir deletion, refusing to cross mount points Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Address Copilot review: root-path check, symlink/TOCTOU-safe writes, legacy marker adoption - _is_same_or_within: drop the redundant, root-mishandling startswith check; commonpath() alone is already component-aware - _ensure_repo_id/_write_manifest: write via temp-file + atomic rename (O_NOFOLLOW, O_EXCL on the temp file) instead of open+write in place, so writes can never go through a pre-existing symlink and are never left half-written - _verify_safe_to_delete: distinguish a legacy empty marker (pre-dates the manifest format, can only be a pre-migration artifact) from a non-empty-but-invalid one (corrupt or forged); adopt the former into the repository instead of refusing to delete it forever Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
135 lines
3.7 KiB
Python
135 lines
3.7 KiB
Python
"""Shared pytest fixtures for cura-te-ipsum tests."""
|
|
import os
|
|
import random
|
|
import string
|
|
import tempfile
|
|
import pytest
|
|
|
|
from curateipsum import backup as bk, fs
|
|
|
|
|
|
# Shared helper functions for fs tests
|
|
def create_file(parent_dir: str, prefix: str = None) -> str:
|
|
"""
|
|
Create a file with random name in parent_dir.
|
|
Returns absolute path to the created file.
|
|
"""
|
|
fd, path = tempfile.mkstemp(prefix=prefix, dir=parent_dir)
|
|
with open(fd, "w") as f:
|
|
f.write(string.printable)
|
|
return path
|
|
|
|
|
|
def create_dir(parent_dir: str, prefix: str = None) -> str:
|
|
"""
|
|
Create a directory with random name in parent_dir.
|
|
Returns absolute path to created directory.
|
|
"""
|
|
return tempfile.mkdtemp(prefix=prefix, dir=parent_dir)
|
|
|
|
|
|
def relpath(full_path: str, src_dir: str, dst_dir: str) -> str:
|
|
"""Get a relative path for entity in src/dst dirs."""
|
|
if full_path.startswith(src_dir):
|
|
p_dir = src_dir
|
|
elif full_path.startswith(dst_dir):
|
|
p_dir = dst_dir
|
|
else:
|
|
raise RuntimeError(f"Path {full_path} is not src_dir nor dst_dir")
|
|
|
|
return full_path[len(p_dir) + 1:]
|
|
|
|
|
|
def check_identical_file(f1_path: str, f2_path: str):
|
|
"""Check that files are identical. Fails test, if not."""
|
|
st1 = os.lstat(f1_path)
|
|
st2 = os.lstat(f2_path)
|
|
|
|
assert st1.st_uid == st2.st_uid
|
|
assert st1.st_gid == st2.st_gid
|
|
assert st1.st_mode == st2.st_mode
|
|
assert st1.st_mtime == st2.st_mtime
|
|
assert st1.st_size == st2.st_size
|
|
|
|
|
|
@pytest.fixture
|
|
def common_fs_dirs(tmp_path):
|
|
"""Create source and destination directories for tests."""
|
|
src_dir = tmp_path / "source"
|
|
dst_dir = tmp_path / "dest"
|
|
src_dir.mkdir()
|
|
dst_dir.mkdir()
|
|
return src_dir, dst_dir
|
|
|
|
|
|
@pytest.fixture
|
|
def backup_dir(tmp_path):
|
|
"""Provide a temporary backup directory."""
|
|
return tmp_path / "backups"
|
|
|
|
|
|
@pytest.fixture
|
|
def source_dir(tmp_path):
|
|
"""Provide a temporary source directory."""
|
|
src = tmp_path / "source"
|
|
src.mkdir()
|
|
return src
|
|
|
|
|
|
@pytest.fixture
|
|
def add_backup(backup_dir):
|
|
"""Factory fixture for creating test backups."""
|
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
def _add_backup(backup_name: str) -> fs.PseudoDirEntry:
|
|
backup = fs.PseudoDirEntry(os.path.join(str(backup_dir), backup_name))
|
|
os.mkdir(backup.path)
|
|
bk.set_backup_marker(backup)
|
|
|
|
fd, path = tempfile.mkstemp(prefix="backup_file_", dir=backup.path)
|
|
with open(fd, "w") as f:
|
|
f.write(''.join(random.choices(string.printable, k=128)))
|
|
return backup
|
|
|
|
return _add_backup
|
|
|
|
|
|
@pytest.fixture
|
|
def check_backup_not_empty():
|
|
"""Helper to verify backup is not empty."""
|
|
def _check(backup: fs.PseudoDirEntry) -> bool:
|
|
return bool(os.listdir(backup.path))
|
|
return _check
|
|
|
|
|
|
@pytest.fixture
|
|
def check_backups(backup_dir, check_backup_not_empty):
|
|
"""Helper to verify expected backups exist."""
|
|
def _check(expected_backups):
|
|
backups_list = [
|
|
name for name in os.listdir(str(backup_dir))
|
|
if name != bk.REPO_ID_FILE
|
|
]
|
|
assert sorted(b.name for b in expected_backups) == sorted(backups_list)
|
|
for b in expected_backups:
|
|
assert check_backup_not_empty(b)
|
|
return _check
|
|
|
|
|
|
@pytest.fixture
|
|
def run_cleanup(backup_dir):
|
|
"""Helper to run cleanup_old_backups with default parameters."""
|
|
def _run(**kwargs):
|
|
cleanup_kwargs = {
|
|
"backups_dir": str(backup_dir),
|
|
"dry_run": False,
|
|
"keep_all": None,
|
|
"keep_daily": None,
|
|
"keep_weekly": None,
|
|
"keep_monthly": None,
|
|
"keep_yearly": None,
|
|
}
|
|
cleanup_kwargs.update(**kwargs)
|
|
bk.cleanup_old_backups(**cleanup_kwargs)
|
|
return _run
|