"""Reusable strict validation primitives for project-owned configuration.""" from __future__ import annotations import re from pathlib import Path from typing import Any from .errors import DocForgeError ID_PATTERN = re.compile(r"[a-z0-9][a-z0-9._-]{1,127}") _SECRET_PARTS = frozenset({".git", ".ssh", ".gnupg", "secrets", "credentials"}) def require_string(document: dict[str, Any], key: str, source: Path) -> str: value = document.get(key) if not isinstance(value, str) or not value.strip(): raise DocForgeError("invalid_source", f"{source.name}: {key} must be a non-empty string") return value.strip() def string_list(value: object, *, key: str, source: Path) -> tuple[str, ...]: if not isinstance(value, list) or any(not isinstance(item, str) or not item for item in value): raise DocForgeError("invalid_source", f"{source.name}: {key} must be a string list") if len(value) != len(set(value)): raise DocForgeError("invalid_source", f"{source.name}: {key} contains duplicates") return tuple(value) def confined_path( root: Path, raw: object, *, field: str, must_exist: bool, expected: str | None = None, ) -> Path: if not isinstance(raw, str) or not raw: raise DocForgeError("invalid_config", f"{field} must be a non-empty relative path") relative = Path(raw) if relative.is_absolute() or ".." in relative.parts: raise DocForgeError("path_escape", f"{field} must stay inside the project root", path=raw) if any(part.lower() in _SECRET_PARTS for part in relative.parts): raise DocForgeError("secret_path", f"{field} may not reference a protected path", path=raw) resolved = (root / relative).resolve(strict=False) if not resolved.is_relative_to(root): raise DocForgeError("path_escape", f"{field} resolves outside the project root", path=raw) if must_exist and not resolved.exists(): raise DocForgeError("missing_path", f"{field} does not exist", path=raw) if expected == "file" and must_exist and not resolved.is_file(): raise DocForgeError("invalid_path", f"{field} must identify a file", path=raw) if expected == "directory" and must_exist and not resolved.is_dir(): raise DocForgeError("invalid_path", f"{field} must identify a directory", path=raw) return resolved def positive_int(value: object, field: str, *, allow_zero: bool = False) -> int: minimum = 0 if allow_zero else 1 if not isinstance(value, int) or isinstance(value, bool) or value < minimum: raise DocForgeError("invalid_config", f"{field} must be an integer >= {minimum}") return value