From 448fa986297845abd53977756e109f123dd0e2ec Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 27 Jul 2026 20:25:27 +0200 Subject: [PATCH] feat(v2): enforce repository safety invariants --- backend/src/backup_tool/api/app.py | 43 +++++++++++++++++++++++- backend/src/backup_tool/repository.py | 48 +++++++++++++++++++++++---- 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/backend/src/backup_tool/api/app.py b/backend/src/backup_tool/api/app.py index 738c437..2cfbb25 100644 --- a/backend/src/backup_tool/api/app.py +++ b/backend/src/backup_tool/api/app.py @@ -3,6 +3,7 @@ import base64 import hashlib from collections.abc import AsyncIterator from datetime import UTC, datetime +from pathlib import Path from typing import Annotated, Any, cast from fastapi import Depends, FastAPI, Header, Request, Response @@ -17,7 +18,7 @@ from backup_tool.cli import build_alembic_config from backup_tool.config import Settings from backup_tool.db.engine import SchemaNotCurrentError, assert_schema_current, create_engine from backup_tool.db.models import ApiToken, AuditEvent, IdempotencyRecord, Repository, Secret, User -from backup_tool.repository import RepositoryError, initialize +from backup_tool.repository import RepositoryError, initialize, inspect_repository from backup_tool.security.auth import ( hash_password, hash_token, @@ -490,6 +491,26 @@ def create_app(settings: Settings) -> FastAPI: "encryption": repository.encryption, } + @app.get("/api/v2/repositories") + async def list_repositories( + db: Annotated[AsyncSession, Depends(session)], + _: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, Any]: + items = list((await db.scalars(select(Repository).order_by(Repository.name))).all()) + return { + "items": [ + { + "id": item.id, + "name": item.name, + "format_version": item.format_version, + "compression": item.compression, + "encryption": item.encryption, + "state": item.state, + } + for item in items + ] + } + @app.get("/api/v2/repositories/{repository_id}") async def get_repository( repository_id: str, @@ -508,6 +529,26 @@ def create_app(settings: Settings) -> FastAPI: "state": repository.state, } + @app.get("/api/v2/repositories/{repository_id}/inspection") + async def inspect_repository_endpoint( + repository_id: str, + db: Annotated[AsyncSession, Depends(session)], + _: Annotated[tuple[User, set[str], bool], Depends(actor)], + ) -> dict[str, Any]: + repository = await db.get(Repository, repository_id) + if repository is None: + raise Problem(404, "resource_not_found", "Repository was not found.") + try: + inspected = inspect_repository(Path(repository.root)) + except RepositoryError as error: + raise Problem(409, "repository_invalid", str(error)) from error + return { + "id": repository.id, + "format_version": inspected.format_version, + "compression": inspected.compression, + "encryption": inspected.encryption, + } + @app.patch("/api/v2/repositories/{repository_id}") async def patch_repository( repository_id: str, diff --git a/backend/src/backup_tool/repository.py b/backend/src/backup_tool/repository.py index 1f722fd..42bd935 100644 --- a/backend/src/backup_tool/repository.py +++ b/backend/src/backup_tool/repository.py @@ -35,12 +35,29 @@ def _contained(root: Path, relative_path: str) -> Path: return candidate +def _canonical_payload(compression: str, encryption: str) -> dict[str, object]: + if compression != "none" or encryption != "none": + raise RepositoryError("requested repository policy is unavailable") + return {"compression": compression, "encryption": encryption, "format_version": 1} + + +def _canonical_json(payload: dict[str, object]) -> str: + return json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n" + + +def _assert_capacity(settings: Settings, root: Path) -> None: + usage = shutil.disk_usage(root) + free_percent = usage.free * 100 / usage.total + if usage.free < settings.min_free_bytes or free_percent < settings.min_free_percent: + raise RepositoryError("repository root does not meet minimum free capacity") + + def initialize( settings: Settings, relative_path: str, compression: str, encryption: str ) -> InitializedRepository: - if compression not in {"none"} or encryption not in {"none"}: - raise RepositoryError("requested repository policy is unavailable") + payload = _canonical_payload(compression, encryption) root = _contained(settings.repository_roots[0], relative_path) + _assert_capacity(settings, root.parent) if root.exists(): raise RepositoryError("repository path already exists") staging = root.with_name(f".{root.name}.staging-{os.urandom(8).hex()}") @@ -48,13 +65,32 @@ def initialize( staging.mkdir(parents=True) (staging / "blobs" / "sha256").mkdir(parents=True) (staging / "manifests").mkdir() - payload = {"format_version": 1, "compression": compression, "encryption": encryption} - file = staging / "repository.json" - file.write_text(json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n") - with file.open("rb") as handle: + metadata = staging / "repository.json" + metadata.write_text(_canonical_json(payload), encoding="utf-8") + with metadata.open("rb") as handle: os.fsync(handle.fileno()) os.replace(staging, root) except Exception: shutil.rmtree(staging, ignore_errors=True) raise return InitializedRepository(root=root, compression=compression, encryption=encryption) + + +def inspect_repository(root: Path) -> InitializedRepository: + metadata = root / "repository.json" + try: + raw = metadata.read_text(encoding="utf-8") + payload = json.loads(raw) + except (OSError, json.JSONDecodeError) as error: + raise RepositoryError("repository metadata is unreadable") from error + if not isinstance(payload, dict): + raise RepositoryError("repository metadata is invalid") + try: + expected = _canonical_json( + _canonical_payload(payload["compression"], payload["encryption"]) + ) + except (KeyError, TypeError, RepositoryError) as error: + raise RepositoryError("repository metadata is invalid") from error + if payload.get("format_version") != 1 or raw != expected: + raise RepositoryError("repository metadata is not canonical") + return InitializedRepository(root=root, compression="none", encryption="none")