"""Vault path resolution shared by the REST routes and the AI tool layer. This is the single implementation of the anti path-traversal check. Routes map :class:`ServiceError` to ``HTTPException`` and tools map it to :class:`backend.tools.context.ToolError`. """ from __future__ import annotations import logging from pathlib import Path from backend.services.errors import ServiceError logger = logging.getLogger("obsigate.services.paths") def _is_within(resolved: Path, root: Path) -> bool: """Return True when *resolved* is *root* or lives below it. The comparison is segment-aware so that a sibling directory whose name merely shares a prefix (``vault`` vs ``vault-evil``) is rejected. A case-insensitive fallback preserves the Windows / Docker behaviour where the resolved casing can differ from the configured root. """ try: resolved.relative_to(root) return True except ValueError: pass try: resolved_parts = tuple(part.lower() for part in resolved.parts) root_parts = tuple(part.lower() for part in root.parts) except Exception: return False return resolved_parts[: len(root_parts)] == root_parts def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path: """Resolve a vault-relative path, rejecting traversal outside the vault. Raises: ServiceError: ``path_error`` (500) when the path cannot be resolved, ``path_outside_vault`` (403) when it escapes the vault root. """ full_path = vault_root / (relative_path or "") try: resolved = full_path.resolve(strict=False) root = vault_root.resolve(strict=False) except Exception as e: logger.error(f"Path resolution error - vault_root: {vault_root}, relative_path: {relative_path}, error: {e}") raise ServiceError(f"Path resolution error: {e!s}", code="path_error", status=500) from e if not _is_within(resolved, root): logger.warning(f"Path outside vault - vault: {root}, requested: {relative_path}, resolved: {resolved}") raise ServiceError( "Access denied: path outside vault", code="path_outside_vault", status=403, details={"path": relative_path}, ) return resolved