fix: enforce canonical factor contract json
CI / lite (pull_request) Successful in 7s

This commit is contained in:
ao gong
2026-09-01 08:35:37 +08:00
parent b853cc0d4c
commit 003d182e49
2 changed files with 61 additions and 0 deletions
+10
View File
@@ -157,6 +157,14 @@ def _duplicate_key_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
def _parse_json_object(value: str | bytes, path: str) -> dict[str, Any]: def _parse_json_object(value: str | bytes, path: str) -> dict[str, Any]:
try:
source_bytes = value if isinstance(value, bytes) else value.encode("utf-8")
except UnicodeEncodeError as exc:
raise FactorContractError(
ContractErrorCode.INVALID_FORMAT,
path,
"invalid UTF-8 JSON",
) from exc
try: try:
loaded = json.loads(value, object_pairs_hook=_duplicate_key_pairs) loaded = json.loads(value, object_pairs_hook=_duplicate_key_pairs)
except (UnicodeDecodeError, json.JSONDecodeError) as exc: except (UnicodeDecodeError, json.JSONDecodeError) as exc:
@@ -168,6 +176,8 @@ def _parse_json_object(value: str | bytes, path: str) -> dict[str, Any]:
if type(loaded) is not dict: if type(loaded) is not dict:
_fail(ContractErrorCode.TYPE_ERROR, path, "top-level JSON value must be an object") _fail(ContractErrorCode.TYPE_ERROR, path, "top-level JSON value must be an object")
_assert_canonical_profile(loaded, path) _assert_canonical_profile(loaded, path)
if source_bytes != canonical_json_bytes(loaded):
_fail(ContractErrorCode.INVALID_FORMAT, path, "JSON is not canonical")
return cast(dict[str, Any], loaded) return cast(dict[str, Any], loaded)
+51
View File
@@ -263,6 +263,57 @@ def test_golden_contracts_are_content_addressed_round_trippable_and_deeply_immut
snapshot.snapshot_id = "rhdsv1:sha256:" + "0" * 64 # type: ignore[misc] snapshot.snapshot_id = "rhdsv1:sha256:" + "0" * 64 # type: ignore[misc]
@pytest.mark.parametrize("variant", ["whitespace", "key_order"])
def test_contract_decoders_reject_non_canonical_json(variant: str) -> None:
fixture = _golden()
snapshot, foundation = _snapshot_and_foundation(fixture)
definition = _golden_definition()
factor_set = FactorSetRef.create(
**_factor_set_arguments(
fixture=fixture,
snapshot=snapshot,
foundation=foundation,
definition=definition,
)
)
binding = LegacyFactorBinding.create(
definition=definition,
legacy_factor_id="factor:demo-momentum",
legacy_version="1.0.0",
legacy_definition_sha256="b" * 64,
legacy_dataset_schema_version="1.0.0",
canonical_input_schema_digest=definition.input_schema_digest,
correspondence_evidence_digest="sha256:" + "5" * 64,
)
def non_canonical(value: str) -> str:
if variant == "whitespace":
return value + "\n"
loaded = json.loads(value)
reversed_items = dict(reversed(tuple(loaded.items())))
return json.dumps(reversed_items, ensure_ascii=False, separators=(",", ":"))
decoders = (
lambda value: FactorDefinition.from_json(value),
lambda value: FactorSetRef.from_json(
value,
definitions=(definition,),
dataset_snapshot=snapshot,
foundation=foundation,
),
lambda value: LegacyFactorBinding.from_json(value, definition=definition),
)
for decoder, encoded in zip(
decoders,
(definition.to_json(), factor_set.to_json(), binding.to_json()),
strict=True,
):
with pytest.raises(FactorContractError) as exc_info:
decoder(non_canonical(encoded))
assert exc_info.value.code is ContractErrorCode.INVALID_FORMAT
assert exc_info.value.path == "$"
def test_factor_definition_identity_is_order_independent_where_semantics_are_unordered() -> None: def test_factor_definition_identity_is_order_independent_where_semantics_are_unordered() -> None:
first_input = FactorInput("prices", "sha256:" + "6" * 64, ("close",)) first_input = FactorInput("prices", "sha256:" + "6" * 64, ("close",))
second_input = FactorInput("volumes", "sha256:" + "7" * 64, ("volume",)) second_input = FactorInput("volumes", "sha256:" + "7" * 64, ("volume",))