"""Strict public CMR collection retrieval and blind-boundary construction.""" from __future__ import annotations import copy import hashlib import json import logging import time from collections.abc import Callable from contextlib import closing from datetime import UTC, datetime from typing import Any from urllib.parse import parse_qsl, urljoin, urlsplit import httpx from pydantic import ValidationError from gcmd_classifier.config import DatasetCMRSettings from gcmd_classifier.datasets.errors import ( CMRError, CMRResponseError, CMRRetrievalError, CMRURLValidationError, ) from gcmd_classifier.datasets.models import ( BlindCollectionView, CMRPreclassificationFailure, CMRRedirectProvenance, CMRRetrievedSource, DatasetErrorRecord, DatasetIdentity, ResolvedCMRCollection, SealedExpertKeywordPayload, ) from gcmd_classifier.logging_config import get_logger, log_event _REDIRECT_STATUSES = {301, 302, 303, 307, 308} _APPROVED_RESPONSE_HEADERS = { "content-type", "content-length", "etag", "last-modified", "cmr-request-id", } _AUTH_QUERY_NAMES = { "access_token", "api_key", "apikey", "auth", "authorization", "bearer", "client_secret", "credential", "jwt", "password", "signature", "sig", "token", } class ValidatedCMRURL: """Validated URL and exact decoded Native ID.""" __slots__ = ("native_id", "url") def __init__(self, *, url: str, native_id: str) -> None: self.url = url self.native_id = native_id def validate_cmr_url( url: str, settings: DatasetCMRSettings | None = None, ) -> ValidatedCMRURL: """Validate a CMR collection-search URL without normalizing it.""" policy = settings or DatasetCMRSettings() if not isinstance(url, str) or not url: raise CMRURLValidationError("CMR_URL_INVALID", "CMR URL must be a non-empty string.") try: parsed = urlsplit(url) port = parsed.port except ValueError as exc: raise CMRURLValidationError("CMR_URL_INVALID", "CMR URL is malformed.") from exc if parsed.scheme != "https": raise CMRURLValidationError("CMR_URL_SCHEME", "CMR URL must use HTTPS.") if parsed.hostname != policy.host: raise CMRURLValidationError("CMR_URL_HOST", "CMR URL host is not approved.") if port not in (None, 443): raise CMRURLValidationError("CMR_URL_PORT", "CMR URL must use the default HTTPS port.") if parsed.username is not None or parsed.password is not None: raise CMRURLValidationError( "CMR_URL_USERINFO", "CMR URL must not contain user information." ) if parsed.path != policy.collection_path: raise CMRURLValidationError("CMR_URL_PATH", "CMR URL path is not approved.") if parsed.fragment: raise CMRURLValidationError("CMR_URL_FRAGMENT", "CMR URL must not contain a fragment.") try: query_items = parse_qsl(parsed.query, keep_blank_values=True, strict_parsing=False) except ValueError as exc: raise CMRURLValidationError("CMR_URL_QUERY", "CMR URL query is malformed.") from exc for name, _ in query_items: normalized_name = name.strip().lower().replace("-", "_") if normalized_name in _AUTH_QUERY_NAMES or any( marker in normalized_name for marker in ("token", "password", "credential", "authorization") ): raise CMRURLValidationError( "CMR_URL_AUTH_QUERY", "CMR URL contains a prohibited authentication-related query parameter.", ) native_values = [value for name, value in query_items if name == "native_id"] if len(native_values) != 1: raise CMRURLValidationError( "CMR_URL_NATIVE_ID_COUNT", "CMR URL must contain exactly one native_id query parameter.", ) if native_values[0] == "": raise CMRURLValidationError( "CMR_URL_NATIVE_ID_EMPTY", "CMR URL native_id query value must not be empty.", ) return ValidatedCMRURL(url=url, native_id=native_values[0]) def _safe_response_metadata(headers: httpx.Headers) -> dict[str, str]: return { name.lower(): value for name, value in headers.items() if name.lower() in _APPROVED_RESPONSE_HEADERS } def _is_json_media_type(value: str | None) -> bool: if not value: return False media_type = value.split(";", 1)[0].strip().lower() return media_type == "application/json" or ( media_type.startswith("application/") and media_type.endswith("+json") ) def _looks_like_login_response(content_type: str | None, body: bytes) -> bool: sample = body[:8192].lower() media_type = (content_type or "").lower() if "text/html" not in media_type and not sample.lstrip().startswith(b"<"): return False return any( marker in sample for marker in ( b"