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