feat(v2): enforce repository safety invariants

This commit is contained in:
2026-07-27 20:25:27 +02:00
parent 1115f63879
commit 448fa98629
2 changed files with 84 additions and 7 deletions
+42 -1
View File
@@ -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,
+42 -6
View File
@@ -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")