diff --git a/backend/services/infonet/tests/test_time_validity_invalid_timestamps.py b/backend/services/infonet/tests/test_time_validity_invalid_timestamps.py new file mode 100644 index 0000000..065e65d --- /dev/null +++ b/backend/services/infonet/tests/test_time_validity_invalid_timestamps.py @@ -0,0 +1,38 @@ +"""Regression coverage for malformed chain timestamps.""" + +from typing import Any + +from services.infonet.time_validity import chain_majority_time, is_event_too_future + + +def _event(node_id: str, timestamp: Any) -> dict[str, Any]: + return {"node_id": node_id, "timestamp": timestamp} + + +def test_chain_majority_time_ignores_malformed_and_nonfinite_timestamps() -> None: + chain = [ + _event("valid-a", 10.0), + _event("bad-text", "not-a-timestamp"), + _event("bad-nan", float("nan")), + _event("bad-inf", float("inf")), + _event("valid-b", 20.0), + ] + + assert chain_majority_time(chain) == 15.0 + + +def test_chain_majority_time_accepts_finite_numeric_strings() -> None: + chain = [_event("a", "10.5"), _event("b", "20.5")] + + assert chain_majority_time(chain) == 15.5 + + +def test_future_check_fails_closed_for_invalid_timestamps() -> None: + assert is_event_too_future(_event("bad-text", "not-a-timestamp"), chain_time=100.0) + assert is_event_too_future(_event("bad-nan", float("nan")), chain_time=100.0) + assert is_event_too_future(_event("bad-inf", float("inf")), chain_time=100.0) + + +def test_future_check_preserves_valid_timestamp_behavior() -> None: + assert not is_event_too_future(_event("near", 110.0), chain_time=100.0) + assert is_event_too_future(_event("far", 10_000.0), chain_time=100.0) diff --git a/backend/services/infonet/time_validity.py b/backend/services/infonet/time_validity.py index 725c657..f556056 100644 --- a/backend/services/infonet/time_validity.py +++ b/backend/services/infonet/time_validity.py @@ -33,6 +33,7 @@ responsibility. from __future__ import annotations +import math import statistics from typing import Any, Iterable @@ -47,6 +48,17 @@ from services.infonet.config import CONFIG _DEFAULT_MEDIAN_N = 11 +def _finite_timestamp(value: Any) -> float | None: + """Return a finite numeric timestamp, or ``None`` when unusable.""" + try: + timestamp = float(value) + except (TypeError, ValueError, OverflowError): + return None + if not math.isfinite(timestamp): + return None + return timestamp + + def chain_majority_time( chain: Iterable[dict[str, Any]], *, @@ -64,22 +76,27 @@ def chain_majority_time( """ if n <= 0: raise ValueError("n must be positive") - events = [e for e in chain if isinstance(e, dict)] - events.sort(key=lambda e: float(e.get("timestamp") or 0.0), reverse=True) + + events: list[tuple[float, dict[str, Any]]] = [] + for event in chain: + if not isinstance(event, dict): + continue + timestamp = _finite_timestamp(event.get("timestamp")) + if timestamp is None: + continue + events.append((timestamp, event)) + events.sort(key=lambda item: item[0], reverse=True) + seen_nodes: set[str] = set() timestamps: list[float] = [] - for ev in events: - node = ev.get("node_id") + for timestamp, event in events: + node = event.get("node_id") if not isinstance(node, str) or not node: continue if node in seen_nodes: continue seen_nodes.add(node) - ts = ev.get("timestamp") - try: - timestamps.append(float(ts)) - except (TypeError, ValueError): - continue + timestamps.append(timestamp) if len(timestamps) >= n: break if not timestamps: @@ -93,24 +110,23 @@ def is_event_too_future( *, chain_time: float | None = None, ) -> bool: - """Is ``event.timestamp`` more than ``max_future_event_drift_sec`` - ahead of ``chain_majority_time``? + """Is ``event.timestamp`` invalid or beyond allowed future drift? Pass ``chain_time`` when the caller has already computed it (e.g. bulk validation of a batch — avoids recomputing the median per event). Otherwise pass ``chain``. + + Invalid/non-finite event timestamps fail closed: they return + ``True`` so callers reject or re-queue them rather than allowing + malformed events through the drift gate. """ if chain_time is None: if chain is None: raise ValueError("Pass chain or chain_time") chain_time = chain_majority_time(chain) - try: - ts = float(event.get("timestamp")) - except (TypeError, ValueError): - # Non-numeric timestamp is its own validation failure — let the - # schema-level check catch that. Drift check itself returns - # False here (we cannot meaningfully compare). - return False + ts = _finite_timestamp(event.get("timestamp")) + if ts is None: + return True drift = float(CONFIG["max_future_event_drift_sec"]) return ts > chain_time + drift