From cd9bf9e45ea4ba6744caad5bad76cf789fb107dd Mon Sep 17 00:00:00 2001 From: Raghuvar Karthik Durga <177662415+drk-ram@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:18:17 -0500 Subject: [PATCH] conformance(loader,report): reject non-standard JSON constants in loads_strict and validate record_digest/build inputs (#648) - Reject non-standard JSON constants (NaN, Infinity, -Infinity) via parse_constant and non-bytes inputs in trace_tests.loader.loads_strict with LoadError. - Reject non-dict record inputs in trace_tests.report.record_digest and trace_tests.report.build, and validate results_by_level level keys and module dicts in build. Closes #648 Signed-off-by: Raghuvar Karthik Durga <177662415+drk-ram@users.noreply.github.com> --- conformance/src/trace_tests/loader.py | 19 +++++- conformance/src/trace_tests/report.py | 11 ++++ tests/test_conformance_report_parity.py | 77 +++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 3 deletions(-) diff --git a/conformance/src/trace_tests/loader.py b/conformance/src/trace_tests/loader.py index b87a9f74..ce11e3c3 100644 --- a/conformance/src/trace_tests/loader.py +++ b/conformance/src/trace_tests/loader.py @@ -20,26 +20,39 @@ def _refuse_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: return obj +def _refuse_constants(token: str) -> Any: + raise LoadError(f"Invalid JSON: non-standard constant {token!r}") + + def loads_strict(raw: bytes) -> Any: """Parse untrusted JSON bytes, raising only :class:`LoadError`. - Three things ``json.loads`` does not do on its own: + Four things ``json.loads`` does not do on its own: * Duplicate member names are refused. ``json.loads`` keeps the last, the signature is checked over that, and a consumer whose parser keeps the first reads a value no signature covered. RFC 8785 is defined over I-JSON (RFC 7493), which forbids duplicates, so such a record has no canonical form to verify in the first place. - * Bytes that are not UTF-8 are a ``LoadError``, not ``UnicodeDecodeError``. + * Non-standard JSON constants (``NaN``, ``Infinity``, ``-Infinity``) are + refused as ``LoadError``. + * Non-``bytes`` inputs and bytes that are not UTF-8 are a ``LoadError``, + not ``AttributeError`` or ``UnicodeDecodeError``. * Nesting deep enough to exhaust the interpreter stack is a ``LoadError``, not ``RecursionError``. """ + if not isinstance(raw, bytes): + raise LoadError(f"Invalid JSON: expected bytes, got {type(raw).__name__}") try: text = raw.decode("utf-8") except UnicodeDecodeError as exc: raise LoadError(f"Invalid JSON: not UTF-8 ({exc})") from exc try: - return json.loads(text, object_pairs_hook=_refuse_duplicates) + return json.loads( + text, + object_pairs_hook=_refuse_duplicates, + parse_constant=_refuse_constants, + ) except json.JSONDecodeError as exc: raise LoadError(f"Invalid JSON: {exc}") from exc except RecursionError as exc: diff --git a/conformance/src/trace_tests/report.py b/conformance/src/trace_tests/report.py index c2c64673..edf1d9bf 100644 --- a/conformance/src/trace_tests/report.py +++ b/conformance/src/trace_tests/report.py @@ -65,6 +65,8 @@ def record_digest(record: dict[str, Any]) -> str: specification, section 0; getting it backwards here would be harmless but confusing, so the choice is stated rather than left implicit. """ + if not isinstance(record, dict): + raise ValueError(f"record must be a JSON object, got {type(record).__name__}") encoded = json.dumps(record, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode( "ascii" ) @@ -171,6 +173,15 @@ def build( library_version: str | None, generated_at: str, ) -> ReportData: + if not isinstance(record, dict): + raise ValueError(f"record must be a JSON object, got {type(record).__name__}") + if not isinstance(results_by_level, dict) or any( + type(lv) is not int or lv not in LEVEL_NAMES or not isinstance(res, dict) + for lv, res in results_by_level.items() + ): + raise ValueError( + "results_by_level must map conformance levels (0, 1, 2) to module finding dicts" + ) levels = [] for level in sorted(results_by_level): failures, unverified = _tally(results_by_level[level], level) diff --git a/tests/test_conformance_report_parity.py b/tests/test_conformance_report_parity.py index cb8c4e7a..63c78986 100644 --- a/tests/test_conformance_report_parity.py +++ b/tests/test_conformance_report_parity.py @@ -106,3 +106,80 @@ def test_report_record_digest_remains_diagnostic(bad_value: object) -> None: assert data.digest == expected assert json.loads(report_mod.to_json(data))["record"]["digest"] == expected assert expected in report_mod.to_html(data) + + +def _assert_raises_only( + exc_type: type[BaseException], fn: Any, *args: Any, **kwargs: Any +) -> None: + try: + fn(*args, **kwargs) + except exc_type: + return + except BaseException as exc: + pytest.fail(f"expected {exc_type.__name__}, got {type(exc).__name__}") + pytest.fail(f"DID NOT RAISE {exc_type.__name__}") + + +@pytest.mark.parametrize( + "raw", + [ + b'{"x": NaN}', + b'{"x": Infinity}', + b'{"x": -Infinity}', + "not-bytes", + None, + ], +) +def test_loads_strict_and_parse_record_reject_non_standard_constants_and_non_bytes( + raw: Any, +) -> None: + loader_mod = importlib.import_module("trace_tests.loader") + assert loader_mod.loads_strict(b'{"x": 1}') == {"x": 1} + _assert_raises_only(loader_mod.LoadError, loader_mod.loads_strict, raw) + _assert_raises_only(loader_mod.LoadError, loader_mod.parse_record, raw) + + +@pytest.mark.parametrize("bad_record", [None, 123, "record", [1, 2]]) +def test_record_digest_and_report_build_reject_non_dict_record( + bad_record: Any, +) -> None: + _assert_raises_only(ValueError, report_mod.record_digest, bad_record) + _assert_raises_only( + ValueError, + report_mod.build, + record=bad_record, + record_path="r.json", + record_format="trace", + results_by_level={0: {}}, + suite_version="0.5.0", + library_version=None, + generated_at="now", + ) + + +@pytest.mark.parametrize( + "bad_results", + [ + "not-a-dict", + {True: {}}, + {-1: {}}, + {3: {}}, + {0: "not-a-dict"}, + ], +) +def test_report_build_rejects_invalid_results_by_level( + bad_results: Any, +) -> None: + record = _load_vector("valid_level0.json") + _assert_raises_only( + ValueError, + report_mod.build, + record=record, + record_path="r.json", + record_format="trace", + results_by_level=bad_results, + suite_version="0.5.0", + library_version=None, + generated_at="now", + ) +