From 14b434780607b77bd742c72a9ccfa239aeb033c4 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 27 Jul 2026 21:16:44 +0200 Subject: [PATCH] fix(v2): enforce repository safety failures --- backend/src/backup_tool/repository.py | 15 ++++-- tests/integration/test_repository_safety.py | 60 +++++++++++++++------ 2 files changed, 56 insertions(+), 19 deletions(-) diff --git a/backend/src/backup_tool/repository.py b/backend/src/backup_tool/repository.py index ca39a32..29e0e8c 100644 --- a/backend/src/backup_tool/repository.py +++ b/backend/src/backup_tool/repository.py @@ -29,10 +29,19 @@ def blob_digest(content: bytes) -> str: def _contained(root: Path, relative_path: str) -> Path: - candidate = (root / relative_path).resolve() - if not relative_path or Path(relative_path).is_absolute() or candidate == root: + relative = Path(relative_path) + if not relative_path or relative.is_absolute(): raise RepositoryError("repository path must be a non-empty relative child") - if root not in candidate.parents: + lexical = root + for part in relative.parts: + lexical /= part + if lexical.is_symlink(): + raise RepositoryError("repository path contains a symlink") + candidate = lexical.resolve() + resolved_root = root.resolve() + if candidate == resolved_root: + raise RepositoryError("repository path must be a non-empty relative child") + if resolved_root not in candidate.parents: raise RepositoryError("repository path escapes configured roots") return candidate diff --git a/tests/integration/test_repository_safety.py b/tests/integration/test_repository_safety.py index b141e18..d818ab3 100644 --- a/tests/integration/test_repository_safety.py +++ b/tests/integration/test_repository_safety.py @@ -3,7 +3,7 @@ from __future__ import annotations import json import shutil from pathlib import Path -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import httpx import pytest @@ -30,7 +30,7 @@ def settings_for(tmp_path: Path, **overrides: object) -> Settings: "min_free_bytes": 1, } values.update(overrides) - return Settings(**values) + return Settings.model_validate(values) def test_partial_initialization_is_removed_on_publish_failure(tmp_path: Path) -> None: @@ -79,6 +79,26 @@ def test_initialization_rejects_insufficient_capacity( initialize(settings, "main", "none", "none") +def test_initialization_emits_complete_repository_protocol_metadata(tmp_path: Path) -> None: + result = initialize(settings_for(tmp_path), "main", "none", "none") + try: + payload = json.loads((result.root / "repository.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise AssertionError("repository metadata was not readable JSON") from error + assert set(payload) == { + "repository_id", + "format_version", + "digest_algorithm", + "compression", + "encryption", + "created_at", + } + assert payload["repository_id"][14] == "7" + assert payload["digest_algorithm"] == "sha256" + assert payload["encryption"] == {"mode": "none", "key_id": None} + assert payload["created_at"].endswith("Z") + + def test_repository_inspection_rejects_noncanonical_metadata(tmp_path: Path) -> None: settings = settings_for(tmp_path) result = initialize(settings, "main", "none", "none") @@ -120,21 +140,29 @@ async def test_database_failure_removes_published_repository(tmp_path: Path) -> await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD}) ).status_code == 201 csrf = client.cookies["backup_tool_csrf"] - with patch( - "backup_tool.api.app.audit", new=AsyncMock(side_effect=RuntimeError("db failure")) - ): - response = await client.post( - "/api/v2/repositories", - json={ - "name": "main", - "relative_path": "main", - "compression": "none", - "encryption": "none", - }, - headers={"X-CSRF-Token": csrf}, - ) + first = await client.post( + "/api/v2/repositories", + json={ + "name": "main", + "relative_path": "first", + "compression": "none", + "encryption": "none", + }, + headers={"X-CSRF-Token": csrf}, + ) + assert first.status_code == 201 + response = await client.post( + "/api/v2/repositories", + json={ + "name": "main", + "relative_path": "second", + "compression": "none", + "encryption": "none", + }, + headers={"X-CSRF-Token": csrf}, + ) assert response.status_code == 409 - assert not (settings.repository_roots[0] / "main").exists() + assert not (settings.repository_roots[0] / "second").exists() await app.state.engine.dispose()