68 lines
2.9 KiB
Python
68 lines
2.9 KiB
Python
"""Reusable strict validation primitives for project-owned configuration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
from typing import cast
|
|
|
|
from .errors import DocForgeError
|
|
|
|
ID_PATTERN = re.compile(r"[a-z0-9][a-z0-9._-]{1,127}")
|
|
AUTHORITIES = frozenset({"authoritative", "approved_plan", "derived", "proposal", "historical"})
|
|
_SECRET_PARTS = frozenset({".git", ".ssh", ".gnupg", "secrets", "credentials"})
|
|
|
|
|
|
def require_string(document: Mapping[str, object], 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):
|
|
raise DocForgeError("invalid_source", f"{source.name}: {key} must be a string list")
|
|
items: list[str] = []
|
|
for item in cast(list[object], value):
|
|
if not isinstance(item, str) or not item:
|
|
raise DocForgeError("invalid_source", f"{source.name}: {key} must be a string list")
|
|
items.append(item)
|
|
if len(items) != len(set(items)):
|
|
raise DocForgeError("invalid_source", f"{source.name}: {key} contains duplicates")
|
|
return tuple(items)
|
|
|
|
|
|
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
|