from __future__ import annotations import json import os from datetime import UTC, datetime, timedelta from pathlib import Path import pytest from backup_tool.gc import purge_repository from backup_tool.security.repository_crypto import encrypt_object, object_aad def write_manifest(path: Path, digests: list[str]) -> None: path.write_text( json.dumps({"entries": [{"blob_digest": digest} for digest in digests]}), encoding="utf-8", ) def age(path: Path, days: int = 8) -> None: stamp = (datetime.now(UTC) - timedelta(days=days)).timestamp() os.utime(path, (stamp, stamp)) def test_gc_purges_tombstoned_manifest_and_only_unreferenced_old_blob( tmp_path: Path, ) -> None: root = tmp_path / "repository" manifests = root / "manifests" blobs = root / "blobs" / "sha256" manifests.mkdir(parents=True) blobs.mkdir(parents=True) kept = "a" * 64 removed = "b" * 64 write_manifest(manifests / "kept.json", [kept]) write_manifest(manifests / "deleted.json", [removed]) (blobs / kept).write_bytes(b"kept") (blobs / removed).write_bytes(b"removed") age(manifests / "deleted.json") age(blobs / removed) report = purge_repository(root, {"deleted"}) assert report.purged_manifests == 1 assert report.purged_blobs == 1 assert (manifests / "kept.json").exists() assert (blobs / kept).exists() assert not (blobs / removed).exists() @pytest.mark.parametrize("encrypted", [False, True], ids=["corrupt", "encrypted"]) def test_gc_fails_closed_for_unreadable_manifest(tmp_path: Path, encrypted: bool) -> None: root = tmp_path / "repository" manifests = root / "manifests" blobs = root / "blobs" / "sha256" manifests.mkdir(parents=True) blobs.mkdir(parents=True) kept = "a" * 64 deleted = "b" * 64 write_manifest(manifests / "kept.json", [kept]) write_manifest(manifests / "deleted.json", [deleted]) unreadable = manifests / "unreadable.json" unreadable.write_bytes( encrypt_object( b"k" * 32, object_aad("repository", "key", "manifest", "unreadable"), b'{"entries": []}', ) if encrypted else b"not json" ) (blobs / kept).write_bytes(b"kept") (blobs / deleted).write_bytes(b"deleted") age(manifests / "deleted.json") age(blobs / deleted) report = purge_repository(root, {"deleted"}) assert report.purged_manifests == 0 assert report.purged_blobs == 0 assert (manifests / "deleted.json").exists() assert (blobs / deleted).exists() def test_gc_purges_encrypted_manifests_with_known_epoch_keys(tmp_path: Path) -> None: root = tmp_path / "repository" manifests = root / "manifests" blobs = root / "blobs" / "sha256" manifests.mkdir(parents=True) blobs.mkdir(parents=True) key = b"k" * 32 repository_id = "repository" key_id = "epoch" kept = "a" * 64 deleted = "b" * 64 for manifest_id, digests in (("kept", [kept]), ("deleted", [deleted])): plaintext = json.dumps( {"entries": [{"blob_digest": digest} for digest in digests]} ).encode() (manifests / f"{manifest_id}.json").write_bytes( encrypt_object( key, object_aad(repository_id, key_id, "manifest", manifest_id), plaintext, ) ) (blobs / kept).write_bytes(b"kept") (blobs / deleted).write_bytes(b"deleted") age(manifests / "deleted.json") age(blobs / deleted) report = purge_repository( root, {"deleted"}, repository_id=repository_id, manifest_keys={"kept": (key_id, key), "deleted": (key_id, key)}, ) assert report.purged_manifests == 1 assert report.purged_blobs == 1 assert (blobs / kept).exists() assert not (blobs / deleted).exists() def test_gc_quarantines_unknown_blob_name(tmp_path: Path) -> None: root = tmp_path / "repository" blobs = root / "blobs" / "sha256" blobs.mkdir(parents=True) unknown = blobs / "not-a-digest" unknown.write_bytes(b"unknown") report = purge_repository(root, set()) assert report.quarantined == 1 assert not unknown.exists() assert (root / "quarantine" / "not-a-digest").exists()