"""Sandboxed path resolution: keep untrusted relative paths inside a root. Guards against path traversal, absolute paths, NUL bytes, backslash smuggling, and symlink escapes (resolved via realpath containment). All public functions raise PathEscapeError on any unsafe input instead of returning an escaping path. Public API: PathEscapeError safe_join(root, *parts) -> str safe_resolve(root, rel) -> str is_within(root, target) -> bool realpath_root(root) -> str """ import os __all__ = ["PathEscapeError", "safe_join", "safe_resolve", "is_within", "realpath_root"] class PathEscapeError(ValueError): pass def realpath_root(root): if not isinstance(root, str) or root == "": raise PathEscapeError("empty root") r = os.path.realpath(root) if not os.path.isdir(r): raise PathEscapeError("root is not an existing directory: %r" % root) return r def _check_part(part): if not isinstance(part, str): raise PathEscapeError("path component must be str, got %r" % (type(part),)) if part == "": return # empty components are harmless in join if "\x00" in part: raise PathEscapeError("NUL byte in path component: %r" % part) # normalize separators: reject backslashes on all platforms to avoid smuggling if "\\" in part: raise PathEscapeError("backslash in path component: %r" % part) if part.startswith("/"): raise PathEscapeError("absolute path component: %r" % part) # split on / and validate each for seg in part.split("/"): if seg in ("", "."): continue if seg == "..": raise PathEscapeError("traversal component '..' in %r" % part) if seg.startswith("/"): raise PathEscapeError("absolute segment in %r" % part) def safe_join(root, *parts): rr = realpath_root(root) for p in parts: _check_part(p) # build candidate without resolving yet candidate = os.path.join(rr, *parts) if parts else rr # normalize (collapses . and redundant slashes) but does not resolve symlinks candidate = os.path.normpath(candidate) # containment check on the normalized (pre-symlink) form. # Special-case root == "/" so we don't require a "//" prefix. if rr == os.sep: if not (candidate == rr or candidate.startswith(rr)): raise PathEscapeError("joined path escapes root: %r" % candidate) else: if candidate != rr and not candidate.startswith(rr + os.sep): raise PathEscapeError("joined path escapes root: %r" % candidate) # now resolve symlinks and re-check containment resolved = os.path.realpath(candidate) if rr == os.sep: if not (resolved == rr or resolved.startswith(rr)): raise PathEscapeError("symlink escape: %r -> %r" % (candidate, resolved)) else: if resolved != rr and not resolved.startswith(rr + os.sep): raise PathEscapeError("symlink escape: %r -> %r" % (candidate, resolved)) return resolved def safe_resolve(root, rel): if isinstance(rel, (list, tuple)): return safe_join(root, *rel) return safe_join(root, rel) def is_within(root, target): try: rr = realpath_root(root) except PathEscapeError: return False if not isinstance(target, str) or target == "": return False try: rt = os.path.realpath(target) except (OSError, ValueError): return False if rr == os.sep: return rt == rr or rt.startswith(rr) return rt == rr or rt.startswith(rr + os.sep)