Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions conformance/src/trace_tests/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions conformance/src/trace_tests/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
Expand Down
77 changes: 77 additions & 0 deletions tests/test_conformance_report_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)

Loading