"""Safe path join: prevent traversal outside a base directory. safe_join(base, *parts) returns an absolute path that is guaranteed to live inside base (resolved). Raises PathTraversalError on any attempt to escape (absolute parts, '..', symlinks that resolve outside, etc.). """ from __future__ import annotations import os from pathlib import Path, PurePath class PathTraversalError(ValueError): """Raised when a joined path escapes the base directory.""" def _is_under(child: Path, parent: Path) -> bool: try: child.relative_to(parent) return True except ValueError: return False def safe_join(base, *parts) -> str: """Join *parts onto base, rejecting anything that escapes base. - base must be an existing directory (or creatable? we require existing). - Each part is treated as a relative path; absolute parts and '..' segments that escape are rejected. - Symlinks are resolved; if a symlink points outside base, rejected. - Empty parts are ignored. - Returns a normalized absolute string path. """ base_path = Path(base).resolve(strict=True) if not base_path.is_dir(): raise PathTraversalError(f"base is not a directory: {base}") # Reject absolute parts up front (they would replace base semantics). for p in parts: if p is None: continue pp = PurePath(p) if pp.is_absolute(): raise PathTraversalError(f"absolute part not allowed: {p!r}") # Also reject drive/root markers on any platform. if pp.drive or pp.root: raise PathTraversalError(f"rooted part not allowed: {p!r}") # Build candidate without resolving first, to detect traversal segments. candidate = base_path for p in parts: if p is None or str(p) == "": continue # Normalize separators; reject NUL. s = str(p) if "\x00" in s: raise PathTraversalError("NUL byte in part") candidate = candidate.joinpath(s) # Resolve; if a component doesn't exist, resolve still normalizes lexicaly # for the non-existent tail, but existing symlinks are resolved. try: resolved = candidate.resolve(strict=False) except (OSError, RuntimeError) as e: raise PathTraversalError(f"cannot resolve path: {e}") from e if not _is_under(resolved, base_path): raise PathTraversalError(f"path escapes base: {resolved}") # Extra check: ensure no intermediate '..' escaped even if final lands inside # (e.g. symlink tricks). Walk lexical parts. lexical = base_path for p in parts: if p is None or str(p) == "": continue lexical = lexical.joinpath(str(p)) # lexical may contain '..'; compute normalized norm = os.path.normpath(str(lexical)) if not _is_under(Path(norm), base_path): raise PathTraversalError(f"lexical path escapes base: {norm}") return str(resolved)