Validate topology and destructive path boundaries (#7)

* 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>
This commit is contained in:
authored and GitHub committed 2026-08-03 22:26:51 +00:00
1 parent a987180653
commit 72a8fe2b59
7 files changed
+450 -33

No files matched your search

+176
View File
@@ -1,4 +1,5 @@
import fcntl
import json
import os
import subprocess
import sys
@@ -741,6 +742,20 @@ class TestBackupIteration:
marker_path = backup_path / marker_name
assert marker_path.exists()
def test_set_backup_marker_records_repo_id(self, backup_dir):
"""Test set_backup_marker records the repository identity."""
backup_dir.mkdir()
backup_path = backup_dir / "20210101_120000"
backup_path.mkdir()
backup_entry = bk.fs.PseudoDirEntry(str(backup_path))
bk.set_backup_marker(backup_entry)
marker_path = backup_path / f"{bk.BACKUP_MARKER}_20210101_120000"
manifest = bk._read_manifest(str(marker_path))
assert manifest["repo_id"] == bk._ensure_repo_id(str(backup_dir))
assert manifest["snapshot"] == "20210101_120000"
def test_set_backup_marker_idempotent(self, backup_dir):
"""Test set_backup_marker is idempotent"""
backup_dir.mkdir()
@@ -755,3 +770,164 @@ class TestBackupIteration:
marker_name = f"{bk.BACKUP_MARKER}_20210101_120000"
marker_path = backup_path / marker_name
assert marker_path.exists()
class TestValidateTopology:
"""Tests for validate_topology source/backups_dir boundary checks."""
def test_disjoint_sources_ok(self, tmp_path):
src1 = tmp_path / "src1"
src2 = tmp_path / "src2"
backups = tmp_path / "backups"
src1.mkdir()
src2.mkdir()
backups.mkdir()
bk.validate_topology([str(src1), str(src2)], str(backups))
def test_source_inside_backups_dir_rejected(self, tmp_path):
backups = tmp_path / "backups"
src = backups / "src"
backups.mkdir()
src.mkdir()
with pytest.raises(bk.BackupFailedError):
bk.validate_topology([str(src)], str(backups))
def test_backups_dir_inside_source_rejected(self, tmp_path):
src = tmp_path / "src"
backups = src / "backups"
src.mkdir()
backups.mkdir()
with pytest.raises(bk.BackupFailedError):
bk.validate_topology([str(src)], str(backups))
def test_source_equals_backups_dir_rejected(self, tmp_path):
shared = tmp_path / "shared"
shared.mkdir()
with pytest.raises(bk.BackupFailedError):
bk.validate_topology([str(shared)], str(shared))
def test_duplicate_source_via_symlink_rejected(self, tmp_path):
src = tmp_path / "src"
backups = tmp_path / "backups"
alias = tmp_path / "alias"
src.mkdir()
backups.mkdir()
alias.symlink_to(src)
with pytest.raises(bk.BackupFailedError):
bk.validate_topology([str(src), str(alias)], str(backups))
def test_duplicate_basename_rejected(self, tmp_path):
parent_a = tmp_path / "a"
parent_b = tmp_path / "b"
backups = tmp_path / "backups"
(parent_a / "data").mkdir(parents=True)
(parent_b / "data").mkdir(parents=True)
backups.mkdir()
with pytest.raises(bk.BackupFailedError):
bk.validate_topology(
[str(parent_a / "data"), str(parent_b / "data")],
str(backups),
)
def test_parent_traversal_rejected(self, tmp_path):
backups = tmp_path / "backups"
outside = tmp_path / "outside"
backups.mkdir()
outside.mkdir()
traversal_src = str(backups / ".." / "outside")
bk.validate_topology([traversal_src], str(backups))
# sanity: same directory reached directly is rejected as an alias
with pytest.raises(bk.BackupFailedError):
bk.validate_topology([traversal_src, str(outside)], str(backups))
class TestDeletionBoundarySafety:
"""Tests that recursive deletion refuses unsafe targets."""
def test_refuses_to_delete_symlinked_snapshot_dir(
self, backup_dir, add_backup, run_cleanup, tmp_path
):
"""A symlink standing in for a snapshot dir must never be
recursively deleted, since shutil.rmtree would follow it."""
real_target = tmp_path / "outside_target"
real_target.mkdir()
(real_target / "canary").write_text("do-not-delete")
add_backup("20220101_0000") # kept, forces the old one to be
# evaluated for removal
old_name = "20200101_0000"
old_marker = f"{bk.BACKUP_MARKER}_{old_name}"
os.symlink(str(real_target), os.path.join(str(backup_dir), old_name))
# marker must exist for _is_backup(), even though this is a symlink
os.makedirs(
os.path.join(str(real_target)), exist_ok=True
)
(real_target / old_marker).write_text("")
run_cleanup(keep_all=0)
assert os.path.lexists(os.path.join(str(backup_dir), old_name))
assert (real_target / "canary").exists()
def test_refuses_to_delete_snapshot_with_wrong_repo_id(
self, backup_dir, add_backup, run_cleanup
):
add_backup("20220101_0000")
old = add_backup("20200101_0000")
marker_path = bk._get_backup_marker(old).path
with open(marker_path, "w") as f:
f.write(json.dumps({"repo_id": "forged", "snapshot": old.name}))
run_cleanup(keep_all=0)
assert os.path.isdir(old.path)
def test_deletes_legitimate_old_backup(
self, backup_dir, add_backup, run_cleanup
):
add_backup("20220101_0000")
old = add_backup("20200101_0000")
run_cleanup(keep_all=0)
assert not os.path.exists(old.path)
def test_cleanup_stale_staging_ignores_symlinked_staging_dir(
self, backup_dir, tmp_path
):
backup_dir.mkdir()
real_target = tmp_path / "outside_target"
real_target.mkdir()
(real_target / "canary").write_text("do-not-delete")
staging_name = f"{bk.STAGING_PREFIX}20220101_0000"
os.symlink(str(real_target),
os.path.join(str(backup_dir), staging_name))
bk.cleanup_stale_staging_dirs(str(backup_dir))
assert os.path.lexists(os.path.join(str(backup_dir), staging_name))
assert (real_target / "canary").exists()
def test_deletes_legacy_backup_with_empty_marker(
self, backup_dir, add_backup, run_cleanup
):
"""A pre-migration empty marker (no manifest) is adopted, not
permanently protected, since it can't be a forged one."""
add_backup("20220101_0000")
old = add_backup("20200101_0000")
marker_path = bk._get_backup_marker(old).path
open(marker_path, "w").close() # simulate pre-migration marker
run_cleanup(keep_all=0)
assert not os.path.exists(old.path)