"""Validated path-segment types for FastAPI route parameters. All URL path segments that come from user input are validated against an allowlist regex and capped at a safe length. FastAPI enforces these at decode time and returns 422 Unprocessable Entity on violation — before any handler logic runs. Usage in route signatures:: from musehub.api.validation import SlugParam, BranchParam, FilePathParam async def my_route(owner: SlugParam, repo_slug: SlugParam, branch: BranchParam): ... """ from typing import Annotated from fastapi import Path # owner / slug / repo_slug — alphanumeric, underscore, hyphen, dot. # Must start with alphanumeric. Max 100 characters. # Blocks: slashes, null bytes, "..", shell metacharacters, Unicode overlong sequences. _SLUG_RE = r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,99}$" # branch — same charset as slug but allows "/" for branch namespaces (e.g. feat/foo). # Pydantic uses Rust's regex engine which does not support lookaheads, so we # block ".." structurally: each path component must start with [a-zA-Z0-9_], # meaning no component can be "." or "..". # Pattern: one or more components of the form [a-zA-Z0-9_][a-zA-Z0-9_.-]*, # optionally separated by "/". Max 200 characters. _BRANCH_RE = r"^[a-zA-Z0-9_][a-zA-Z0-9_.-]*(/[a-zA-Z0-9_][a-zA-Z0-9_.-]*)*$" # file path inside a repo (e.g. "src/main.py", "README.md"). # Each path component is either: # - a normal name: starts with [a-zA-Z0-9_] # - a dotfile: starts with "." followed immediately by [a-zA-Z0-9_] # This structurally blocks ".", "..", and traversal sequences without lookaheads. # Max 1000 characters enforced by max_length on the Path() field info. _SEGMENT = r"(?:\.[a-zA-Z0-9_]|[a-zA-Z0-9_])[a-zA-Z0-9_.-]*" _FILE_PATH_RE = rf"^{_SEGMENT}(\/{_SEGMENT})*$" SlugParam = Annotated[ str, Path(pattern=_SLUG_RE, max_length=100, min_length=1), ] BranchParam = Annotated[ str, Path(pattern=_BRANCH_RE, max_length=200, min_length=1), ] FilePathParam = Annotated[ str, Path(pattern=_FILE_PATH_RE, max_length=1000, min_length=1), ]