diff --git a/src/soul_protocol/spec/trust.py b/src/soul_protocol/spec/trust.py index b6f24c8..188997e 100644 --- a/src/soul_protocol/spec/trust.py +++ b/src/soul_protocol/spec/trust.py @@ -174,12 +174,20 @@ def _canonical_json(data: Any) -> bytes: """ if isinstance(data, BaseModel): data = data.model_dump(mode="json") + + def reject_non_json(value: Any) -> None: + raise TypeError( + "trust payloads must be JSON-native " + "(dict/list/str/int/float/bool/None). " + f"Got: {type(value).__name__}" + ) + return json.dumps( data, sort_keys=True, separators=(",", ":"), ensure_ascii=True, - default=str, + default=reject_non_json, ).encode("utf-8") @@ -190,6 +198,10 @@ def compute_payload_hash(payload: dict) -> str: chain — only its hash. So a verifier with the original payload and the chain entry can prove the payload existed and was not tampered with. """ + if isinstance(payload, BaseModel): + raise TypeError("pass payload.model_dump(mode='json') to compute_payload_hash") + if not isinstance(payload, dict): + raise TypeError("compute_payload_hash payload must be a dict") return hashlib.sha256(_canonical_json(payload)).hexdigest() diff --git a/tests/test_trust_chain/test_trust_entry.py b/tests/test_trust_chain/test_trust_entry.py index ac1905e..05a811e 100644 --- a/tests/test_trust_chain/test_trust_entry.py +++ b/tests/test_trust_chain/test_trust_entry.py @@ -5,6 +5,10 @@ from __future__ import annotations from datetime import UTC, datetime +from pathlib import Path + +import pytest +from pydantic import BaseModel from soul_protocol.spec.trust import ( GENESIS_PREV_HASH, @@ -36,6 +40,27 @@ def test_payload_hash_is_64_hex_chars(): int(h, 16) # Raises if not hex +def test_payload_hash_rejects_non_dict_payload(): + with pytest.raises(TypeError, match="payload must be a dict"): + compute_payload_hash(["not", "a", "dict"]) # type: ignore[arg-type] + + +def test_payload_hash_rejects_pydantic_model_directly(): + class Payload(BaseModel): + action: str + + with pytest.raises(TypeError, match=r"payload\.model_dump\(mode='json'\)"): + compute_payload_hash(Payload(action="memory.write")) # type: ignore[arg-type] + + +def test_payload_hash_rejects_non_json_native_values(): + with pytest.raises(TypeError, match="Got: datetime"): + compute_payload_hash({"at": datetime(2026, 1, 1, tzinfo=UTC)}) + + with pytest.raises(TypeError, match="Got: WindowsPath|Got: PosixPath"): + compute_payload_hash({"path": Path("memory/core.json")}) + + def test_entry_hash_excludes_signature(): """Entry hash should be the same whether signature is set or not.""" e1 = TrustEntry(