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
14 changes: 13 additions & 1 deletion src/soul_protocol/spec/trust.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand All @@ -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()


Expand Down
25 changes: 25 additions & 0 deletions tests/test_trust_chain/test_trust_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down