diff --git a/scripts/mas_evidence_backup.py b/scripts/mas_evidence_backup.py index a0b6966..c14c44f 100644 --- a/scripts/mas_evidence_backup.py +++ b/scripts/mas_evidence_backup.py @@ -13,6 +13,10 @@ freshness Fails when the latest verified upload is older than STALE_HOURS, its receipt is not verified, or the local ciphertext no longer matches it. +After each verified upload, local run directories older than +LOCAL_RETENTION_DAYS are pruned. The current run is never pruned, and +nothing at the provider is ever deleted. + Credentials come only from /etc/mas-backup/primary.env through the systemd EnvironmentFile. No TEM or SMTP credential and no age identity is read here. """ @@ -22,6 +26,8 @@ from __future__ import annotations import argparse import json import os +import re +import shutil import sys from datetime import UTC, datetime, timedelta from pathlib import Path @@ -36,6 +42,8 @@ STATE = Path("/var/lib/mas-evidence-backup") RECIPIENT_FILE = Path("/etc/mas-backup/age-recipient") LATEST = "latest-offsite.json" STALE_HOURS = 26 +LOCAL_RETENTION_DAYS = 30 # owner-accepted 2026-11-03 +_RUN_NAME = re.compile(r"\A\d{8}T\d{6}Z\Z") APPLICATION = "mas" @@ -120,7 +128,25 @@ def backup(state=STATE, capture=None, client_factory=None, now=None): archive.checkpoint(state / LATEST, {**receipt, "run": stamp, "capture": public}) receipt = publish_run(run_dir, captured["captured_at"], client, save_latest) - return {**receipt, "run": stamp, "capture": public} + pruned = prune_local(state, stamp, now or datetime.now(UTC)) + return {**receipt, "run": stamp, "capture": public, + "local_retention_days": LOCAL_RETENTION_DAYS, "local_runs_pruned": pruned} + + +def prune_local(state, keep, now, days=LOCAL_RETENTION_DAYS): + """Remove dated run directories older than `days`, never `keep`. Local only.""" + cutoff = now - timedelta(days=days) + removed = [] + for entry in sorted(state.iterdir()): + if entry.name == keep or not _RUN_NAME.fullmatch(entry.name): + continue + if entry.is_symlink() or not entry.is_dir() or entry.parent != state: + continue + stamp = datetime.strptime(entry.name, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC) + if stamp < cutoff: + shutil.rmtree(entry) + removed.append(entry.name) + return removed def freshness(state=STATE, now=None): diff --git a/tests/test_mas_evidence_backup.py b/tests/test_mas_evidence_backup.py index 1cd925c..089a7e9 100644 --- a/tests/test_mas_evidence_backup.py +++ b/tests/test_mas_evidence_backup.py @@ -136,3 +136,33 @@ def test_recipient_must_be_a_public_age_key(tmp_path): bad.write_text("AGE-SECRET-KEY-1XYZ") with pytest.raises(worker.archive.Refused): worker.recipient(bad) + + +def test_prune_removes_only_old_dated_runs_and_never_the_current(env, tmp_path): + env.mkdir(mode=0o700) + for name in ("20261001T041500Z", "20261010T041500Z", "20261101T041500Z", "notes", "20261001T041500Z-x"): + (env / name).mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (env / "20260901T041500Z").symlink_to(outside) + removed = worker.prune_local(env, "20261103T041500Z", NOW) + assert removed == ["20261001T041500Z"] + remaining = sorted(p.name for p in env.iterdir()) + assert remaining == ["20260901T041500Z", "20261001T041500Z-x", "20261010T041500Z", "20261101T041500Z", "notes"] + assert outside.exists() + assert worker.prune_local(env, "20261010T041500Z", NOW + timedelta(days=40)) == ["20261101T041500Z"] + + +def test_backup_reports_retention_and_prunes_after_verified_upload(env): + env.mkdir(mode=0o700) + (env / "20260901T041500Z").mkdir() + result = worker.backup(env, FakeCapture(), lambda: Provider(), now=NOW) + assert result["local_retention_days"] == 30 and result["local_runs_pruned"] == ["20260901T041500Z"] + + +def test_failed_upload_prunes_nothing(env): + env.mkdir(mode=0o700) + (env / "20260901T041500Z").mkdir() + with pytest.raises(TimeoutError): + worker.backup(env, FakeCapture(), lambda: Provider(fail_complete=True), now=NOW) + assert (env / "20260901T041500Z").exists()