diff --git a/src/ca2a_runtime/hardware_acceptance.py b/src/ca2a_runtime/hardware_acceptance.py index ec82f45e..93b35be7 100644 --- a/src/ca2a_runtime/hardware_acceptance.py +++ b/src/ca2a_runtime/hardware_acceptance.py @@ -101,6 +101,21 @@ def _nonce_hash(nonce: str) -> str: return hashlib.sha256(nonce.encode()).hexdigest() +def _loads_strict(text: str) -> Any: + def _no_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in pairs: + if key in out: + raise ValueError(f"duplicate JSON object key: {key!r}") + out[key] = value + return out + + def _no_nonfinite(token: str) -> Any: + raise ValueError(f"non-finite JSON constant is not allowed: {token}") + + return json.loads(text, object_pairs_hook=_no_duplicate_keys, parse_constant=_no_nonfinite) + + def real_provider() -> SevSnpProvider: if not SevSnpProvider.detect(): raise AttestationUnsupported("acceptance run requires a real non-paravisor SNP guest") @@ -207,10 +222,10 @@ def run(config: dict[str, Any], base: Path, mode: str, log: ReceiptLog) -> None: log.write("receiver_ready", provider="sev-snp") service.serve_forever() return - chain = [ - DelegationCredential.from_dict(item) - for item in json.loads((base / config["chain"]).read_text(encoding="utf-8")) - ] + raw_chain = _loads_strict((base / config["chain"]).read_text(encoding="utf-8")) + if not isinstance(raw_chain, list): + raise ValueError("chain file must contain a JSON array") + chain = [DelegationCredential.from_dict(item) for item in raw_chain] key = Ed25519PrivateKey.from_private_bytes( bytes.fromhex((base / config["holder_key"]).read_text(encoding="utf-8").strip()) ) @@ -257,7 +272,9 @@ def main() -> int: args = parser.parse_args() log = ReceiptLog(args.receipt, args.run_id) try: - config = json.loads(args.config.read_text(encoding="utf-8")) + config = _loads_strict(args.config.read_text(encoding="utf-8")) + if not isinstance(config, dict): + raise ValueError("config must be a JSON object") run(config, args.config.resolve().parent, args.mode, log) except (CA2AError, ValueError, KeyError, OSError) as exc: log.write("run_failed", error_type=type(exc).__name__) diff --git a/src/ca2a_runtime/transport/a2a_adapter.py b/src/ca2a_runtime/transport/a2a_adapter.py index fc26b65f..32480345 100644 --- a/src/ca2a_runtime/transport/a2a_adapter.py +++ b/src/ca2a_runtime/transport/a2a_adapter.py @@ -52,6 +52,16 @@ def collect_metadata( 2. ``message.metadata`` / ``params.message.metadata`` 3. Explicit ``metadata`` keyword (caller override) """ + if not isinstance(message_or_request, dict): + raise TransportError( + "message_or_request must be a JSON object", + detail=f"got {type(message_or_request).__name__}", + ) + if metadata is not None and not isinstance(metadata, dict): + raise TransportError( + "metadata must be a mapping", + detail=f"got {type(metadata).__name__}", + ) collected: dict[str, Any] = {} params = message_or_request.get("params") @@ -140,10 +150,10 @@ def parse_peer_request( parent_raw = meta[KEY_PARENT_RECORD_HASH] if parent_raw is None: parent_record_hash: str | None = None - elif isinstance(parent_raw, str): + elif isinstance(parent_raw, str) and parent_raw: parent_record_hash = parent_raw else: - raise TransportError("parent_record_hash must be a string or null") + raise TransportError("parent_record_hash must be a non-empty string or null") sealed_payload: bytes | None = None if KEY_SEALED_PAYLOAD in meta and meta[KEY_SEALED_PAYLOAD] is not None: diff --git a/src/ca2a_runtime/transport/a2a_sdk.py b/src/ca2a_runtime/transport/a2a_sdk.py index 030d5134..c6a39b89 100644 --- a/src/ca2a_runtime/transport/a2a_sdk.py +++ b/src/ca2a_runtime/transport/a2a_sdk.py @@ -29,6 +29,7 @@ from __future__ import annotations +import math from dataclasses import dataclass from typing import TYPE_CHECKING, Any @@ -231,7 +232,12 @@ def metadata_from_sdk_message(message: Any) -> dict[str, Any]: continue for field in ("depth", "not_before", "not_after"): value = credential.get(field) - if isinstance(value, float) and value.is_integer(): + if ( + isinstance(value, float) + and math.isfinite(value) + and value.is_integer() + and abs(int(value)) <= 9_007_199_254_740_991 + ): credential[field] = int(value) return result diff --git a/tests/unit/test_hardware_acceptance.py b/tests/unit/test_hardware_acceptance.py index 34dfaffe..3700edaa 100644 --- a/tests/unit/test_hardware_acceptance.py +++ b/tests/unit/test_hardware_acceptance.py @@ -197,3 +197,38 @@ def offer(self, nonce): receiver = [json.loads(line) for line in receiver_log.path.read_text().splitlines()] assert receiver[-1]["event"] == "operation_completed" assert receiver[-1]["stage"] == "receiver_offer" + + +@pytest.mark.parametrize( + "bad_cfg", + [ + '{"a": 1, "a": 2}', + '{"a": NaN}', + "[1, 2]", + ], +) +def test_harness_rejects_duplicate_keys_nonfinite_and_non_dict_config( + tmp_path, monkeypatch, bad_cfg +): + config = tmp_path / "config.json" + config.write_text(bad_cfg, encoding="utf-8") + receipt = tmp_path / "receipt.jsonl" + monkeypatch.setattr( + "sys.argv", + ["hardware_acceptance", "call", str(config), "--receipt", str(receipt), "--run-id", "r1"], + ) + assert harness.main() == 1 + entry = json.loads(receipt.read_text()) + assert entry["event"] == "run_failed" + assert entry["error_type"] == "ValueError" + + +def test_adapter_rejects_non_dict_inputs_empty_parent_hash_and_sdk_unsafe_float(): + from ca2a_runtime.errors import TransportError + from ca2a_runtime.transport import a2a_adapter + + for bad_msg in ([1, 2], "str", None): + with pytest.raises(TransportError): + a2a_adapter.collect_metadata(bad_msg) # type: ignore[arg-type] + with pytest.raises(TransportError): + a2a_adapter.collect_metadata({}, metadata=["not-a-dict"]) # type: ignore[arg-type]