|
| 1 | +"""Producer-boundary freshness evidence for one bounded feed snapshot.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import datetime as dt |
| 6 | +import email.utils |
| 7 | +import hashlib |
| 8 | +import json |
| 9 | +import re |
| 10 | +from collections.abc import Mapping |
| 11 | +from urllib.parse import urlsplit |
| 12 | + |
| 13 | +import defusedxml.ElementTree as ET |
| 14 | +from defusedxml.common import DefusedXmlException |
| 15 | + |
| 16 | + |
| 17 | +EVIDENCE_VERSION = "pert.source_freshness_evidence.v1" |
| 18 | +POLICY_VERSION = "monday_weekly_8d_5m.v1" |
| 19 | +MAX_BODY_BYTES = 1024 * 1024 |
| 20 | +MAX_HEADER_VALUE_BYTES = 4096 |
| 21 | +MAX_FRESHNESS_AGE = dt.timedelta(days=8) |
| 22 | +FUTURE_TOLERANCE = dt.timedelta(minutes=5) |
| 23 | +_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$") |
| 24 | +_TIMESTAMP_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$") |
| 25 | +_EVIDENCE_KEYS = frozenset( |
| 26 | + { |
| 27 | + "evidence_version", |
| 28 | + "policy_version", |
| 29 | + "feed_id", |
| 30 | + "source_identity_digest", |
| 31 | + "body_sha256", |
| 32 | + "reference_time", |
| 33 | + "selected_signal", |
| 34 | + "signals", |
| 35 | + "decision", |
| 36 | + } |
| 37 | +) |
| 38 | +_SIGNAL_KEYS = frozenset({"kind", "present", "valid", "value"}) |
| 39 | +_SIGNAL_KINDS = ( |
| 40 | + "atom_feed_updated", |
| 41 | + "rss_channel_last_build_date", |
| 42 | + "http_last_modified", |
| 43 | + "http_date", |
| 44 | +) |
| 45 | +_SELECTABLE = frozenset(_SIGNAL_KINDS[:3]) |
| 46 | + |
| 47 | + |
| 48 | +class FreshnessError(ValueError): |
| 49 | + """Stable, sanitized producer freshness contract error.""" |
| 50 | + |
| 51 | + def __init__(self, code: str) -> None: |
| 52 | + self.code = code |
| 53 | + super().__init__(code) |
| 54 | + |
| 55 | + |
| 56 | +def _fail(code: str) -> None: |
| 57 | + raise FreshnessError(code) |
| 58 | + |
| 59 | + |
| 60 | +def _string(value: object, code: str, *, allow_empty: bool = False) -> str: |
| 61 | + if type(value) is not str or (not allow_empty and not value) or any(ord(char) < 0x20 for char in value): |
| 62 | + _fail(code) |
| 63 | + return value |
| 64 | + |
| 65 | + |
| 66 | +def _timestamp(value: object, code: str = "freshness_time_invalid") -> dt.datetime: |
| 67 | + if type(value) is not str or not _TIMESTAMP_RE.fullmatch(value): |
| 68 | + _fail(code) |
| 69 | + try: |
| 70 | + return dt.datetime.fromisoformat(value[:-1] + "+00:00") |
| 71 | + except ValueError: |
| 72 | + _fail(code) |
| 73 | + |
| 74 | + |
| 75 | +def _canonical_timestamp(value: dt.datetime) -> str: |
| 76 | + return value.astimezone(dt.UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") |
| 77 | + |
| 78 | + |
| 79 | +def _parse_date_signal(value: object) -> tuple[bool, str | None]: |
| 80 | + if type(value) is not str or not value or len(value) > MAX_HEADER_VALUE_BYTES: |
| 81 | + return False, None |
| 82 | + try: |
| 83 | + parsed = email.utils.parsedate_to_datetime(value) |
| 84 | + if parsed is None: |
| 85 | + raise ValueError |
| 86 | + if parsed.tzinfo is None: |
| 87 | + parsed = parsed.replace(tzinfo=dt.UTC) |
| 88 | + return True, _canonical_timestamp(parsed) |
| 89 | + except (TypeError, ValueError, OverflowError): |
| 90 | + try: |
| 91 | + parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) |
| 92 | + if parsed.tzinfo is None: |
| 93 | + parsed = parsed.replace(tzinfo=dt.UTC) |
| 94 | + return True, _canonical_timestamp(parsed) |
| 95 | + except (TypeError, ValueError, OverflowError): |
| 96 | + return False, None |
| 97 | + |
| 98 | + |
| 99 | +def _headers(value: Mapping[str, str]) -> dict[str, str]: |
| 100 | + if not isinstance(value, Mapping): |
| 101 | + _fail("freshness_headers_invalid") |
| 102 | + result: dict[str, str] = {} |
| 103 | + try: |
| 104 | + items = list(value.items()) |
| 105 | + except (AttributeError, RuntimeError, TypeError, ValueError): |
| 106 | + _fail("freshness_headers_invalid") |
| 107 | + for key, item in items: |
| 108 | + if ( |
| 109 | + type(key) is not str |
| 110 | + or any(ord(char) < 0x20 for char in key) |
| 111 | + or type(item) is not str |
| 112 | + or len(item.encode("utf-8")) > MAX_HEADER_VALUE_BYTES |
| 113 | + ): |
| 114 | + _fail("freshness_headers_invalid") |
| 115 | + normalized = key.lower() |
| 116 | + if normalized in result: |
| 117 | + _fail("freshness_headers_invalid") |
| 118 | + result[normalized] = item |
| 119 | + return result |
| 120 | + |
| 121 | + |
| 122 | +def _source_identity(source_url: object) -> str: |
| 123 | + value = _string(source_url, "source_identity_invalid") |
| 124 | + try: |
| 125 | + parsed = urlsplit(value) |
| 126 | + if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password: |
| 127 | + _fail("source_identity_invalid") |
| 128 | + except ValueError: |
| 129 | + _fail("source_identity_invalid") |
| 130 | + return hashlib.sha256(value.encode("utf-8")).hexdigest() |
| 131 | + |
| 132 | + |
| 133 | +def _xml_signal(body: bytes, kind: str) -> tuple[bool, str | None]: |
| 134 | + try: |
| 135 | + root = ET.fromstring(body, forbid_dtd=True, forbid_entities=True, forbid_external=True) |
| 136 | + except (DefusedXmlException, ET.ParseError, LookupError, UnicodeError, ValueError, RecursionError): |
| 137 | + _fail("source_freshness_invalid") |
| 138 | + if root.tag == "rss": |
| 139 | + channels = [child for child in root if child.tag == "channel"] |
| 140 | + if len(channels) != 1: |
| 141 | + _fail("source_freshness_invalid") |
| 142 | + if kind == "rss_channel_last_build_date": |
| 143 | + values = [child.text.strip() for child in channels[0] if child.tag == "lastBuildDate" and child.text] |
| 144 | + if len(values) > 1: |
| 145 | + _fail("source_freshness_invalid") |
| 146 | + if not values: |
| 147 | + return False, None |
| 148 | + return True, _parse_date_signal(values[0])[1] |
| 149 | + return False, None |
| 150 | + if root.tag == "{http://www.w3.org/2005/Atom}feed": |
| 151 | + if kind == "atom_feed_updated": |
| 152 | + values = [child.text.strip() for child in root if child.tag == "{http://www.w3.org/2005/Atom}updated" and child.text] |
| 153 | + if len(values) > 1: |
| 154 | + _fail("source_freshness_invalid") |
| 155 | + if not values: |
| 156 | + return False, None |
| 157 | + return True, _parse_date_signal(values[0])[1] |
| 158 | + return False, None |
| 159 | + _fail("source_freshness_invalid") |
| 160 | + |
| 161 | + |
| 162 | +def _signal(kind: str, present: bool, value: str | None, valid: bool) -> dict[str, object]: |
| 163 | + return {"kind": kind, "present": present, "valid": valid, "value": value} |
| 164 | + |
| 165 | + |
| 166 | +def _validate_signal(value: object) -> dict[str, object]: |
| 167 | + if not isinstance(value, Mapping) or set(value) != _SIGNAL_KEYS: |
| 168 | + _fail("freshness_invalid") |
| 169 | + kind = _string(value["kind"], "freshness_invalid") |
| 170 | + if kind not in _SIGNAL_KINDS or type(value["present"]) is not bool or type(value["valid"]) is not bool: |
| 171 | + _fail("freshness_invalid") |
| 172 | + signal_value = value["value"] |
| 173 | + if signal_value is not None: |
| 174 | + _timestamp(signal_value, "freshness_invalid") |
| 175 | + if not value["present"] and (signal_value is not None or value["valid"]): |
| 176 | + _fail("freshness_invalid") |
| 177 | + if value["valid"] and signal_value is None: |
| 178 | + _fail("freshness_invalid") |
| 179 | + return {key: value[key] for key in ("kind", "present", "valid", "value")} |
| 180 | + |
| 181 | + |
| 182 | +def _validate_evidence(value: object) -> dict[str, object]: |
| 183 | + if not isinstance(value, Mapping) or set(value) != _EVIDENCE_KEYS: |
| 184 | + _fail("freshness_invalid") |
| 185 | + if value["evidence_version"] != EVIDENCE_VERSION or value["policy_version"] != POLICY_VERSION: |
| 186 | + _fail("freshness_invalid") |
| 187 | + _string(value["feed_id"], "freshness_invalid") |
| 188 | + for key in ("source_identity_digest", "body_sha256"): |
| 189 | + if type(value[key]) is not str or not _DIGEST_RE.fullmatch(value[key]): |
| 190 | + _fail("freshness_invalid") |
| 191 | + _timestamp(value["reference_time"], "freshness_invalid") |
| 192 | + signals = value["signals"] |
| 193 | + if not isinstance(signals, list) or len(signals) != len(_SIGNAL_KINDS): |
| 194 | + _fail("freshness_invalid") |
| 195 | + parsed = [_validate_signal(item) for item in signals] |
| 196 | + if [item["kind"] for item in parsed] != list(_SIGNAL_KINDS): |
| 197 | + _fail("freshness_invalid") |
| 198 | + for item in parsed[:3]: |
| 199 | + if item["present"] and not item["valid"]: |
| 200 | + _fail("freshness_invalid") |
| 201 | + first_present = next((item for item in parsed[:3] if item["present"]), None) |
| 202 | + selected = value["selected_signal"] |
| 203 | + if selected is not None: |
| 204 | + selected = _validate_signal(selected) |
| 205 | + if selected["kind"] not in _SELECTABLE or not selected["valid"]: |
| 206 | + _fail("freshness_invalid") |
| 207 | + matching = next(item for item in parsed if item["kind"] == selected["kind"]) |
| 208 | + if selected != matching: |
| 209 | + _fail("freshness_invalid") |
| 210 | + if selected != first_present: |
| 211 | + _fail("freshness_invalid") |
| 212 | + if selected is not None: |
| 213 | + reference = _timestamp(value["reference_time"]) |
| 214 | + selected_time = _timestamp(selected["value"]) |
| 215 | + if selected_time > reference + FUTURE_TOLERANCE or reference - selected_time > MAX_FRESHNESS_AGE: |
| 216 | + _fail("freshness_invalid") |
| 217 | + if value["decision"] not in {"eligible", "source_freshness_unverified"}: |
| 218 | + _fail("freshness_invalid") |
| 219 | + if (value["decision"] == "eligible") != (selected is not None): |
| 220 | + _fail("freshness_invalid") |
| 221 | + return { |
| 222 | + "evidence_version": value["evidence_version"], |
| 223 | + "policy_version": value["policy_version"], |
| 224 | + "feed_id": value["feed_id"], |
| 225 | + "source_identity_digest": value["source_identity_digest"], |
| 226 | + "body_sha256": value["body_sha256"], |
| 227 | + "reference_time": value["reference_time"], |
| 228 | + "selected_signal": selected, |
| 229 | + "signals": parsed, |
| 230 | + "decision": value["decision"], |
| 231 | + } |
| 232 | + |
| 233 | + |
| 234 | +def _canonical(value: object) -> bytes: |
| 235 | + try: |
| 236 | + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") |
| 237 | + except (TypeError, ValueError, UnicodeError, RecursionError): |
| 238 | + _fail("freshness_serialization_invalid") |
| 239 | + |
| 240 | + |
| 241 | +def build_freshness_evidence( |
| 242 | + *, |
| 243 | + feed_id: str, |
| 244 | + source_url: str, |
| 245 | + body: bytes, |
| 246 | + response_headers: Mapping[str, str], |
| 247 | + reference_time: str, |
| 248 | +) -> bytes: |
| 249 | + if type(body) is not bytes or len(body) > MAX_BODY_BYTES: |
| 250 | + _fail("freshness_body_invalid") |
| 251 | + reference = _timestamp(reference_time) |
| 252 | + headers = _headers(response_headers) |
| 253 | + atom_present, atom_value = _xml_signal(body, "atom_feed_updated") |
| 254 | + rss_present, rss_value = _xml_signal(body, "rss_channel_last_build_date") |
| 255 | + last_modified = headers.get("last-modified") |
| 256 | + http_present = last_modified is not None |
| 257 | + http_valid, http_value = _parse_date_signal(last_modified) if http_present else (False, None) |
| 258 | + date_value = headers.get("date") |
| 259 | + date_present = date_value is not None |
| 260 | + date_valid, date_parsed = _parse_date_signal(date_value) if date_present else (False, None) |
| 261 | + signals = [ |
| 262 | + _signal("atom_feed_updated", atom_present, atom_value, atom_present and atom_value is not None), |
| 263 | + _signal("rss_channel_last_build_date", rss_present, rss_value, rss_present and rss_value is not None), |
| 264 | + _signal("http_last_modified", http_present, http_value, http_present and http_valid), |
| 265 | + _signal("http_date", date_present, date_parsed, date_present and date_valid), |
| 266 | + ] |
| 267 | + selected: dict[str, object] | None = None |
| 268 | + for item in signals[:3]: |
| 269 | + if item["present"]: |
| 270 | + if not item["valid"]: |
| 271 | + _fail("source_freshness_invalid") |
| 272 | + selected = item |
| 273 | + break |
| 274 | + if selected is None: |
| 275 | + decision = "source_freshness_unverified" |
| 276 | + else: |
| 277 | + selected_time = _timestamp(selected["value"]) |
| 278 | + if selected_time > reference + FUTURE_TOLERANCE: |
| 279 | + _fail("source_freshness_future") |
| 280 | + if reference - selected_time > MAX_FRESHNESS_AGE: |
| 281 | + _fail("source_freshness_stale") |
| 282 | + decision = "eligible" |
| 283 | + payload = { |
| 284 | + "evidence_version": EVIDENCE_VERSION, |
| 285 | + "policy_version": POLICY_VERSION, |
| 286 | + "feed_id": _string(feed_id, "freshness_invalid"), |
| 287 | + "source_identity_digest": _source_identity(source_url), |
| 288 | + "body_sha256": hashlib.sha256(body).hexdigest(), |
| 289 | + "reference_time": _canonical_timestamp(reference), |
| 290 | + "selected_signal": selected, |
| 291 | + "signals": signals, |
| 292 | + "decision": decision, |
| 293 | + } |
| 294 | + wire = _canonical(payload) |
| 295 | + read_freshness_evidence(wire) |
| 296 | + return wire |
| 297 | + |
| 298 | + |
| 299 | +def read_freshness_evidence(wire: bytes) -> dict[str, object]: |
| 300 | + if type(wire) is not bytes: |
| 301 | + _fail("freshness_wire_invalid") |
| 302 | + |
| 303 | + def pairs(items: list[tuple[str, object]]) -> dict[str, object]: |
| 304 | + result: dict[str, object] = {} |
| 305 | + for key, value in items: |
| 306 | + if key in result: |
| 307 | + _fail("freshness_duplicate_key") |
| 308 | + result[key] = value |
| 309 | + return result |
| 310 | + |
| 311 | + try: |
| 312 | + value = json.loads(wire.decode("utf-8"), object_pairs_hook=pairs) |
| 313 | + except FreshnessError: |
| 314 | + raise |
| 315 | + except (UnicodeError, json.JSONDecodeError, RecursionError): |
| 316 | + _fail("freshness_wire_invalid") |
| 317 | + parsed = _validate_evidence(value) |
| 318 | + if _canonical(parsed) != wire: |
| 319 | + _fail("freshness_noncanonical") |
| 320 | + return parsed |
0 commit comments