(bugfix) Make snapshot pruning robust to archived permissions

Repair user permissions inside snapshot trees before deleting them so
retention prune and incomplete cleanup can remove directories copied with
restrictive rsync archive modes.

Add path validation for scheduled/manual snapshot deletes and cover
non-traversable nested directories in retention tests.
This commit is contained in:
2026-05-28 20:58:37 +02:00
parent f86c67aeee
commit 3893df4640
2 changed files with 141 additions and 7 deletions

View File

@@ -154,6 +154,68 @@ class SqlRetentionTests(TestCase):
],
)
def test_apply_deletes_snapshot_with_non_traversable_nested_directory(self) -> None:
with TemporaryDirectory() as tmp:
prefix = Path(tmp) / "home"
host = HostConfig.objects.create(
host="web-01",
address="web-01.example.test",
retention_daily=0,
retention_weekly=0,
retention_monthly=0,
retention_yearly=0,
)
old_dir = Path(tmp) / "backups" / host.host / "scheduled" / "20260518-021500Z__OLD"
restricted_dir = old_dir / "data" / "var" / "lib" / "snapd" / "void"
restricted_dir.mkdir(parents=True)
restricted_dir.joinpath("state").write_text("preserved permissions\n")
restricted_dir.chmod(0)
new_dir = Path(tmp) / "backups" / host.host / "scheduled" / "20260519-021500Z__NEW"
new_dir.mkdir(parents=True)
old = self._snapshot(host, old_dir.name, path=str(old_dir))
self._snapshot(host, new_dir.name, path=str(new_dir))
result = run_sql_retention_apply(
prefix=prefix,
host=host.host,
kind="scheduled",
protect_bases=False,
yes=True,
max_delete=1,
acquire_lock=False,
)
self.assertFalse(old_dir.exists())
self.assertFalse(SnapshotRecord.objects.filter(pk=old.pk).exists())
self.assertEqual(result["deleted"][0]["dirname"], old.dirname)
def test_apply_rejects_scheduled_snapshot_path_outside_host_kind_directory(self) -> None:
host = HostConfig.objects.create(
host="web-01",
address="web-01.example.test",
retention_daily=0,
retention_weekly=0,
retention_monthly=0,
retention_yearly=0,
)
self._snapshot(
host,
"20260518-021500Z__OLD",
path="/backups/web-01/manual/20260518-021500Z__OLD",
)
self._snapshot(host, "20260519-021500Z__NEW")
with self.assertRaisesRegex(ConfigError, "unexpected snapshot path"):
run_sql_retention_apply(
prefix=Path("/tmp/pobsync-test"),
host=host.host,
kind="scheduled",
protect_bases=False,
yes=True,
max_delete=1,
acquire_lock=False,
)
def test_apply_respects_max_delete(self) -> None:
host = HostConfig.objects.create(
host="web-01",
@@ -213,6 +275,36 @@ class SqlRetentionTests(TestCase):
self.assertEqual(purged.action, PurgedSnapshot.Action.INCOMPLETE_CLEANUP)
self.assertEqual(purged.reason, "manual incomplete cleanup")
def test_incomplete_cleanup_deletes_non_traversable_nested_directory(self) -> None:
with TemporaryDirectory() as tmp:
prefix = Path(tmp) / "home"
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
incomplete_dir = Path(tmp) / "backups" / host.host / ".incomplete" / "20260519-031500Z__BROKEN01"
restricted_dir = incomplete_dir / "data" / "var" / "lib" / "snapd" / "void"
restricted_dir.mkdir(parents=True)
restricted_dir.joinpath("state").write_text("interrupted\n")
restricted_dir.chmod(0)
record = SnapshotRecord.objects.create(
host=host,
kind=SnapshotRecord.Kind.INCOMPLETE,
dirname=incomplete_dir.name,
path=str(incomplete_dir),
status="failed",
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
)
result = run_incomplete_cleanup(
prefix=prefix,
host=host.host,
yes=True,
max_delete=1,
acquire_lock=False,
)
self.assertFalse(incomplete_dir.exists())
self.assertFalse(SnapshotRecord.objects.filter(pk=record.pk).exists())
self.assertEqual(result["deleted"][0]["dirname"], incomplete_dir.name)
def test_incomplete_cleanup_respects_max_delete(self) -> None:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
SnapshotRecord.objects.create(