from __future__ import annotations from pathlib import Path, PurePosixPath def normalise_relative_path(path: str) -> PurePosixPath: """Normalise and validate a project-relative path.""" cleaned = path.strip().replace("\\", "/") if not cleaned: raise ValueError("Path cannot be empty.") candidate = PurePosixPath(cleaned) if candidate.is_absolute(): raise PermissionError(f"Absolute paths are not allowed: {path}") if ".." in candidate.parts: raise PermissionError(f"Parent-directory traversal is not allowed: {path}") return candidate def reject_repeated_project_prefix( project_directory: Path, path: str, ) -> None: """Reject paths such as sandbox/project-name/src/file.py.""" candidate = normalise_relative_path(path) parts = candidate.parts project_name = project_directory.name if len(parts) >= 2 and parts[0].lower() == "sandbox" and parts[1] == project_name: remaining_parts = parts[2:] suggested_path = ( PurePosixPath(*remaining_parts).as_posix() if remaining_parts else "." ) raise PermissionError( "Do not include the sandbox or project-directory prefix. " f"Use `{suggested_path}` instead of `{path}`." ) def resolve_project_path( project_directory: Path, path: str, ) -> Path: """Resolve a project-relative path and ensure it stays inside the project.""" cleaned_path = path.strip() reject_repeated_project_prefix( project_directory, cleaned_path, ) relative_path = normalise_relative_path(cleaned_path) root = project_directory.resolve() resolved = (root / relative_path).resolve() if not resolved.is_relative_to(root): raise PermissionError(f"Path escapes the project directory: {path!r}") return resolved