"""Credential-free port for discovering and acquiring Evidence objects.""" import re from collections.abc import Iterable, Mapping, Sequence from datetime import UTC, datetime from enum import Enum from typing import Protocol, Self, runtime_checkable from urllib.parse import parse_qsl, urlsplit, urlunsplit from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, field_validator class FrozenDict(dict): """A JSON-serializable dict whose mutation operations are disabled.""" def _immutable(self, *args, **kwargs): raise TypeError("frozen JSON metadata cannot be mutated") __delitem__ = _immutable __ior__ = _immutable __setitem__ = _immutable clear = _immutable pop = _immutable popitem = _immutable setdefault = _immutable update = _immutable _CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])") _SEPARATORS = re.compile(r"[^a-z0-9]+") _NAMESPACED_VALUE = re.compile(r"^[a-z][a-z0-9_-]*:[A-Za-z0-9._:-]+$") _CREDENTIAL_KEYS = { "apikey", "authorization", "authtoken", "bearertoken", "clientsecret", "credential", "credentials", "password", "passwd", "privatekey", "refreshtoken", "sessioncookie", "xapikey", "accesstoken", } _JSON_METADATA = TypeAdapter(dict[str, JsonValue]) def _normalize_key(key: str) -> str: return _SEPARATORS.sub("", _CAMEL_BOUNDARY.sub("_", key).lower()) def _is_credential_key(key: str) -> bool: return _normalize_key(key) in _CREDENTIAL_KEYS def _reject_credentials(value, path: str = "metadata") -> None: if isinstance(value, Mapping): for key, child in value.items(): if _is_credential_key(str(key)): raise ValueError(f"credential-like metadata key is not allowed: {path}.{key}") _reject_credentials(child, f"{path}.{key}") elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): for index, child in enumerate(value): _reject_credentials(child, f"{path}[{index}]") def freeze_json(value): """Recursively freeze a Pydantic-validated JSON value without changing its JSON shape.""" if isinstance(value, Mapping): return FrozenDict({str(key): freeze_json(child) for key, child in value.items()}) if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): return tuple(freeze_json(child) for child in value) return value def validate_safe_metadata(value: dict[str, JsonValue]) -> FrozenDict: _reject_credentials(value) return freeze_json(value) def validate_canonical_uri(value: str) -> str: try: parsed = urlsplit(value) _ = parsed.port except ValueError as error: raise ValueError("invalid canonical URI") from error if not parsed.scheme: raise ValueError("canonical URI must include a scheme") if parsed.username is not None or parsed.password is not None: raise ValueError("canonical URI must not contain credentials in userinfo") for key, _ in parse_qsl(parsed.query, keep_blank_values=True): if _is_credential_key(key): raise ValueError("canonical URI must not contain credentials in query parameters") return value def canonical_provenance_uri(value: str) -> str: """Return only stable URI identity; transport query/fragment data is never provenance.""" try: parsed = urlsplit(value) _ = parsed.port except ValueError as error: raise ValueError("invalid canonical URI") from error if not parsed.scheme: raise ValueError("canonical URI must include a scheme") if parsed.username is not None or parsed.password is not None: raise ValueError("canonical URI must not contain credentials in userinfo") return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", "")) def normalize_aware_datetime(value: datetime | None) -> datetime | None: if value is None: return None if value.tzinfo is None or value.utcoffset() is None: raise ValueError("datetime must be timezone-aware") return value.astimezone(UTC) def validate_namespaced_value(value: str) -> str: if not _NAMESPACED_VALUE.fullmatch(value): raise ValueError("value must be namespaced as ':'") return value class _EvidenceValue(BaseModel): model_config = ConfigDict( frozen=True, extra="forbid", revalidate_instances="always", validate_default=True, ser_json_bytes="base64", val_json_bytes="base64", ) def model_copy(self, *, update: Mapping[str, object] | None = None, deep: bool = False) -> Self: """Copy through validation; Pydantic's unchecked update-copy is unsafe for contracts.""" data = self.model_dump(round_trip=True) if update: data.update(update) return type(self).model_validate(data) class SourceObject(_EvidenceValue): source_id: str = Field(min_length=1) uri: str = Field(min_length=1) fingerprint: str = Field(min_length=1) modified_at: datetime | None = None metadata: dict[str, JsonValue] = Field(default_factory=dict) _source_id = field_validator("source_id")(validate_namespaced_value) _fingerprint = field_validator("fingerprint")(validate_namespaced_value) _safe_uri = field_validator("uri")(validate_canonical_uri) _aware_modified_at = field_validator("modified_at")(normalize_aware_datetime) _frozen_metadata = field_validator("metadata")(validate_safe_metadata) class AcquiredDocument(_EvidenceValue): """Transport result; bytes use explicit base64 encoding in JSON mode.""" source: SourceObject content: bytes media_type: str | None = None acquired_at: datetime | None = None metadata: dict[str, JsonValue] = Field(default_factory=dict) _aware_acquired_at = field_validator("acquired_at")(normalize_aware_datetime) _frozen_metadata = field_validator("metadata")(validate_safe_metadata) class EvidenceSourceErrorCategory(str, Enum): TRANSIENT = "transient" PERMANENT = "permanent" class EvidenceSourceError(Exception): """Classified source failure with credential-free structured diagnostics.""" def __init__( self, _message: str, *, category: EvidenceSourceErrorCategory, details: dict[str, JsonValue] | None = None, ) -> None: super().__init__("evidence source operation failed") object.__setattr__(self, "category", EvidenceSourceErrorCategory(category)) object.__setattr__( self, "details", validate_safe_metadata(_JSON_METADATA.validate_python(details or {})), ) def __setattr__(self, name: str, value) -> None: if name in {"args", "category", "details"} and hasattr(self, name): raise AttributeError(f"{name} is immutable") super().__setattr__(name, value) @property def retryable(self) -> bool: return self.category is EvidenceSourceErrorCategory.TRANSIENT @runtime_checkable class EvidenceSource(Protocol): def discover(self) -> Iterable[SourceObject]: ... def acquire(self, item: SourceObject) -> AcquiredDocument: ...