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 import hashlib
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path
from typing import Annotated, Any, cast from typing import Annotated, Any, cast
from fastapi import Depends, FastAPI, Header, Request, Response 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.config import Settings
from backup_tool.db.engine import SchemaNotCurrentError, assert_schema_current, create_engine 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.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 ( from backup_tool.security.auth import (
hash_password, hash_password,
hash_token, hash_token,
@@ -490,6 +491,26 @@ def create_app(settings: Settings) -> FastAPI:
"encryption": repository.encryption, "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}") @app.get("/api/v2/repositories/{repository_id}")
async def get_repository( async def get_repository(
repository_id: str, repository_id: str,
@@ -508,6 +529,26 @@ def create_app(settings: Settings) -> FastAPI:
"state": repository.state, "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}") @app.patch("/api/v2/repositories/{repository_id}")
async def patch_repository( async def patch_repository(
repository_id: str, repository_id: str,
+42 -6
View File
@@ -35,12 +35,29 @@ def _contained(root: Path, relative_path: str) -> Path:
return candidate 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( def initialize(
settings: Settings, relative_path: str, compression: str, encryption: str settings: Settings, relative_path: str, compression: str, encryption: str
) -> InitializedRepository: ) -> InitializedRepository:
if compression not in {"none"} or encryption not in {"none"}: payload = _canonical_payload(compression, encryption)
raise RepositoryError("requested repository policy is unavailable")
root = _contained(settings.repository_roots[0], relative_path) root = _contained(settings.repository_roots[0], relative_path)
_assert_capacity(settings, root.parent)
if root.exists(): if root.exists():
raise RepositoryError("repository path already exists") raise RepositoryError("repository path already exists")
staging = root.with_name(f".{root.name}.staging-{os.urandom(8).hex()}") staging = root.with_name(f".{root.name}.staging-{os.urandom(8).hex()}")
@@ -48,13 +65,32 @@ def initialize(
staging.mkdir(parents=True) staging.mkdir(parents=True)
(staging / "blobs" / "sha256").mkdir(parents=True) (staging / "blobs" / "sha256").mkdir(parents=True)
(staging / "manifests").mkdir() (staging / "manifests").mkdir()
payload = {"format_version": 1, "compression": compression, "encryption": encryption} metadata = staging / "repository.json"
file = staging / "repository.json" metadata.write_text(_canonical_json(payload), encoding="utf-8")
file.write_text(json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n") with metadata.open("rb") as handle:
with file.open("rb") as handle:
os.fsync(handle.fileno()) os.fsync(handle.fileno())
os.replace(staging, root) os.replace(staging, root)
except Exception: except Exception:
shutil.rmtree(staging, ignore_errors=True) shutil.rmtree(staging, ignore_errors=True)
raise raise
return InitializedRepository(root=root, compression=compression, encryption=encryption) 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")