PINK: fix ctypes c_char_p null-byte truncation (INVALID_INTENT_PARSE)

_to_rust_bytes() centralises all Python→Rust JSON serialisation:
- _json_null_clean() strips U+0000 from all string values recursively
- ensure_ascii=True guarantees no 0x00 in output bytes
- All _json() call sites migrated; mode/verbosity now .encode("ascii")
- 9 null-safety unit tests added to TestRustBytesNullSafety

Root cause: ctypes.c_char_p silently truncates at first 0x00 byte,
causing serde_json "premature end of input at column 41" on EXIT intents
with BNB-USDT leverage values. Long-term fix: Rust FFI (ptr, len) pairs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-06-03 18:30:10 +02:00
parent beef39eaf5
commit a89e766da1
2 changed files with 147 additions and 19 deletions

View File

@@ -1479,3 +1479,80 @@ class TestNormalizeEngForTui:
eng = {"open_positions": 0, "slot": {}}
out = self._norm(eng)
assert out["open_positions"] == [], "zero open_positions must become empty list"
# ============================================================
# _to_rust_bytes / _json_null_clean — null-byte safety
# ============================================================
class TestRustBytesNullSafety:
"""_to_rust_bytes must never produce a 0x00 byte in its output.
Root cause: ctypes.c_char_p treats the first 0x00 as a C null terminator,
silently truncating the JSON before Rust's serde_json sees the full payload.
Reproduces the INVALID_INTENT_PARSE bug seen during BingX VST smoke test.
"""
def _encode(self, payload):
from prod.clean_arch.dita_v2.rust_backend import _to_rust_bytes
return _to_rust_bytes(payload)
def _clean(self, obj):
from prod.clean_arch.dita_v2.rust_backend import _json_null_clean
return _json_null_clean(obj)
def test_no_null_bytes_in_normal_exit_intent(self):
payload = {
"action": "EXIT",
"asset": "BNB-USDT",
"leverage": 1.3465735902799727,
"target_size": 1.76,
"reference_price": 66337.09,
"limit_price": 0.0,
"trade_id": "t1",
"metadata": {},
}
encoded = self._encode(payload)
assert b"\x00" not in encoded, "EXIT intent must have no null bytes"
def test_no_null_bytes_when_string_contains_u0000(self):
"""A string value containing \\u0000 must not produce a null byte in output."""
payload = {"event_id": "BX\x00data", "price": 100.0}
encoded = self._encode(payload)
assert b"\x00" not in encoded, "Null char in string must not produce null byte"
def test_no_null_bytes_in_seen_event_ids(self):
"""seen_event_ids list is serialized with all other slot fields."""
payload = {"seen_event_ids": ["123", "456\x00789", "999"], "size": 1.76}
encoded = self._encode(payload)
assert b"\x00" not in encoded, "seen_event_ids with null chars must be clean"
def test_no_null_bytes_in_nested_metadata(self):
payload = {"metadata": {"venue_note": "order\x00ok", "id": 42}, "asset": "ENJ-USDT"}
encoded = self._encode(payload)
assert b"\x00" not in encoded, "Nested metadata null chars must be sanitized"
def test_output_is_valid_json(self):
import json
payload = {"action": "ENTER", "asset": "BNB-USDT", "leverage": 2.7, "seen_event_ids": ["e1"]}
encoded = self._encode(payload)
parsed = json.loads(encoded)
assert parsed["action"] == "ENTER"
def test_json_null_clean_replaces_null_in_string(self):
result = self._clean({"key": "val\x00ue"})
assert "\x00" not in result["key"]
assert "val" in result["key"]
def test_json_null_clean_recursion(self):
obj = {"nested": {"list": ["a\x00b", 1, {"deep": "x\x00y"}]}}
cleaned = self._clean(obj)
assert "\x00" not in cleaned["nested"]["list"][0]
assert "\x00" not in cleaned["nested"]["list"][2]["deep"]
def test_normal_ascii_payload_roundtrips_intact(self):
import json
payload = {"action": "EXIT", "asset": "BTC-USDT", "leverage": 1.5, "size": 0.001}
encoded = self._encode(payload)
assert json.loads(encoded)["asset"] == "BTC-USDT"
assert json.loads(encoded)["leverage"] == 1.5