1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages
DocForge2/src/docforge/config_validation.py

69 lines
2.9 KiB
Python
Raw Normal View History

"""Reusable strict validation primitives for project-owned configuration."""
from __future__ import annotations
import re
2026-07-24 22:26:01 -04:00
from collections.abc import Mapping
from pathlib import Path
2026-07-24 22:26:01 -04:00
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"})
2026-07-24 22:26:01 -04:00
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, ...]:
2026-07-24 22:26:01 -04:00
if not isinstance(value, list):
raise DocForgeError("invalid_source", f"{source.name}: {key} must be a string list")
2026-07-24 22:26:01 -04:00
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")
2026-07-24 22:26:01 -04:00
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