feat(v2): enforce repository safety invariants
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user