refactor: relocate MeshCore tools to examples/meshcore/
MeshCore is a protocol implementation ON TOP of DongLoRa, not part of the dongle itself. Moving it to examples/ makes the separation clear while keeping it accessible as a real-world usage example.
This commit is contained in:
36 files changed
+29
-3
No files matched your search
Executable
+607
@@ -0,0 +1,607 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build hand-crafted MeshCore test vectors as individual JSON files."""
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
OUT = Path(__file__).parent / "hand"
|
||||
OUT.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def write_case(name: str, case: dict):
|
||||
path = OUT / f"{name}.json"
|
||||
path.write_text(json.dumps(case, indent=2, ensure_ascii=False) + "\n")
|
||||
print(f" {path.name}")
|
||||
|
||||
|
||||
# ── Packet building helpers ───────────────────────────────────────
|
||||
|
||||
def mc_header(payload_type: int, route_type: int, ver: int = 0) -> int:
|
||||
return (ver << 6) | (payload_type << 2) | route_type
|
||||
|
||||
|
||||
def path_len_byte(hop_count: int, hash_size: int) -> int:
|
||||
return ((hash_size - 1) << 6) | (hop_count & 0x3F)
|
||||
|
||||
|
||||
DUMMY_PUBKEY = bytes(range(32))
|
||||
DUMMY_SIG = bytes(64)
|
||||
TS_2026 = struct.pack("<I", 1774600000) # ~2026-03-24
|
||||
|
||||
|
||||
def advert_packet(app_data: bytes, route_type: int = 1,
|
||||
path_hops: list[bytes] | None = None,
|
||||
hash_size: int = 1, pubkey: bytes = DUMMY_PUBKEY,
|
||||
timestamp: bytes = TS_2026) -> bytes:
|
||||
hdr = bytes([mc_header(0x04, route_type)])
|
||||
hops = path_hops or []
|
||||
pl = bytes([path_len_byte(len(hops), hash_size)])
|
||||
path = b"".join(hops)
|
||||
payload = pubkey + timestamp + DUMMY_SIG + app_data
|
||||
return hdr + pl + path + payload
|
||||
|
||||
|
||||
def peer_packet(payload_type: int, route_type: int, dst: int, src: int,
|
||||
mac: bytes, ciphertext: bytes,
|
||||
path_hops: list[bytes] | None = None,
|
||||
hash_size: int = 1, tc: tuple[int, int] | None = None) -> bytes:
|
||||
hdr = bytes([mc_header(payload_type, route_type)])
|
||||
hops = path_hops or []
|
||||
pl_byte = bytes([path_len_byte(len(hops), hash_size)])
|
||||
path = b"".join(hops)
|
||||
body = bytes([dst, src]) + mac + ciphertext
|
||||
tc_bytes = b""
|
||||
if tc is not None:
|
||||
tc_bytes = struct.pack("<HH", tc[0], tc[1])
|
||||
return hdr + tc_bytes + pl_byte + path + body
|
||||
|
||||
|
||||
def ack_packet(crc: int, route_type: int = 1,
|
||||
path_hops: list[bytes] | None = None,
|
||||
hash_size: int = 1) -> bytes:
|
||||
hdr = bytes([mc_header(0x03, route_type)])
|
||||
hops = path_hops or []
|
||||
pl_byte = bytes([path_len_byte(len(hops), hash_size)])
|
||||
path = b"".join(hops)
|
||||
return hdr + pl_byte + path + struct.pack("<I", crc)
|
||||
|
||||
|
||||
def group_packet(payload_type: int, ch_hash: int, mac: bytes,
|
||||
ciphertext: bytes, route_type: int = 1,
|
||||
path_hops: list[bytes] | None = None,
|
||||
hash_size: int = 1) -> bytes:
|
||||
hdr = bytes([mc_header(payload_type, route_type)])
|
||||
hops = path_hops or []
|
||||
pl_byte = bytes([path_len_byte(len(hops), hash_size)])
|
||||
path = b"".join(hops)
|
||||
return hdr + pl_byte + path + bytes([ch_hash]) + mac + ciphertext
|
||||
|
||||
|
||||
def anon_req_packet(dst: int, ephem_pub: bytes, mac: bytes, ciphertext: bytes,
|
||||
route_type: int = 1, path_hops: list[bytes] | None = None,
|
||||
hash_size: int = 1) -> bytes:
|
||||
hdr = bytes([mc_header(0x07, route_type)])
|
||||
hops = path_hops or []
|
||||
pl_byte = bytes([path_len_byte(len(hops), hash_size)])
|
||||
path = b"".join(hops)
|
||||
return hdr + pl_byte + path + bytes([dst]) + ephem_pub + mac + ciphertext
|
||||
|
||||
|
||||
def trace_packet(tag: int, auth_code: int, flags: int,
|
||||
trace_hashes: bytes = b"",
|
||||
route_type: int = 1,
|
||||
path_hops: list[bytes] | None = None,
|
||||
hash_size: int = 1) -> bytes:
|
||||
hdr = bytes([mc_header(0x09, route_type)])
|
||||
hops = path_hops or []
|
||||
pl_byte = bytes([path_len_byte(len(hops), hash_size)])
|
||||
path = b"".join(hops)
|
||||
payload = struct.pack("<II", tag, auth_code) + bytes([flags]) + trace_hashes
|
||||
return hdr + pl_byte + path + payload
|
||||
|
||||
|
||||
def multipart_packet(remaining: int, inner_type: int, inner_payload: bytes,
|
||||
route_type: int = 1) -> bytes:
|
||||
hdr = bytes([mc_header(0x0A, route_type)])
|
||||
pl_byte = bytes([path_len_byte(0, 1)])
|
||||
control = bytes([(remaining << 4) | (inner_type & 0x0F)])
|
||||
return hdr + pl_byte + control + inner_payload
|
||||
|
||||
|
||||
def control_packet(flags_byte: int, payload: bytes,
|
||||
route_type: int = 1) -> bytes:
|
||||
hdr = bytes([mc_header(0x0B, route_type)])
|
||||
pl_byte = bytes([path_len_byte(0, 1)])
|
||||
return hdr + pl_byte + bytes([flags_byte]) + payload
|
||||
|
||||
|
||||
# ── Test cases ────────────────────────────────────────────────────
|
||||
|
||||
print("Building hand-crafted test vectors...")
|
||||
|
||||
# ADVERT: basic chat node with name
|
||||
write_case("advert_basic", {
|
||||
"description": "ADVERT flood, chat node with simple ASCII name",
|
||||
"tags": ["advert", "flood", "name", "chat"],
|
||||
"packet_hex": advert_packet(bytes([0x81]) + b"TestNode").hex(),
|
||||
"valid": True,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ADVERT",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": 1, "hops": []},
|
||||
"payload": {
|
||||
"pubkey_prefix": DUMMY_PUBKEY[:4].hex(),
|
||||
"timestamp": 1774600000,
|
||||
"advert": {
|
||||
"flags": 0x81,
|
||||
"node_type": "chat",
|
||||
"location": None,
|
||||
"name": "TestNode"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# ADVERT: repeater with valid GPS location + name
|
||||
write_case("advert_location", {
|
||||
"description": "ADVERT flood, repeater with valid GPS coords and name",
|
||||
"tags": ["advert", "flood", "location", "repeater"],
|
||||
"packet_hex": advert_packet(
|
||||
bytes([0x92])
|
||||
+ struct.pack("<i", 37774900) + struct.pack("<i", -122419400)
|
||||
+ b"HilltopRptr"
|
||||
).hex(),
|
||||
"valid": True,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ADVERT",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": 1, "hops": []},
|
||||
"payload": {
|
||||
"pubkey_prefix": DUMMY_PUBKEY[:4].hex(),
|
||||
"timestamp": 1774600000,
|
||||
"advert": {
|
||||
"flags": 0x92,
|
||||
"node_type": "repeater",
|
||||
"location": {"lat": 37.7749, "lon": -122.4194},
|
||||
"name": "HilltopRptr"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# ADVERT: emoji name (multi-byte UTF-8)
|
||||
emoji_name = "\U0001f985 Raptor" # 🦅 Raptor
|
||||
write_case("advert_emoji_name", {
|
||||
"description": "ADVERT flood, chat node with emoji in name (multi-byte UTF-8)",
|
||||
"tags": ["advert", "flood", "emoji", "name", "utf8"],
|
||||
"packet_hex": advert_packet(bytes([0x81]) + emoji_name.encode()).hex(),
|
||||
"valid": True,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ADVERT",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": 1, "hops": []},
|
||||
"payload": {
|
||||
"pubkey_prefix": DUMMY_PUBKEY[:4].hex(),
|
||||
"timestamp": 1774600000,
|
||||
"advert": {
|
||||
"flags": 0x81,
|
||||
"node_type": "chat",
|
||||
"location": None,
|
||||
"name": emoji_name
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# ADVERT: feat flags set but data is actually name (scoring test)
|
||||
write_case("advert_feat_flags_as_name", {
|
||||
"description": "ADVERT flood, feat1+feat2 bits set but bytes are actually the name",
|
||||
"tags": ["advert", "flood", "feat", "scoring", "emoji"],
|
||||
"packet_hex": advert_packet(
|
||||
bytes([0xe1]) + "\U0001f3d4\ufe0f MtnNode".encode()
|
||||
).hex(),
|
||||
"valid": True,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ADVERT",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": 1, "hops": []},
|
||||
"payload": {
|
||||
"pubkey_prefix": DUMMY_PUBKEY[:4].hex(),
|
||||
"timestamp": 1774600000,
|
||||
"advert": {
|
||||
"flags": 0xe1,
|
||||
"node_type": "chat",
|
||||
"location": None,
|
||||
"name": "\U0001f3d4\ufe0f MtnNode"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# ADVERT: no app_data (bare 100-byte advert)
|
||||
write_case("advert_no_appdata", {
|
||||
"description": "ADVERT flood, bare minimum (no app_data)",
|
||||
"tags": ["advert", "flood", "minimal"],
|
||||
"packet_hex": advert_packet(b"").hex(),
|
||||
"valid": True,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ADVERT",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": 1, "hops": []},
|
||||
"payload": {
|
||||
"pubkey_prefix": DUMMY_PUBKEY[:4].hex(),
|
||||
"timestamp": 1774600000,
|
||||
"advert": None
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# ADVERT: sensor node type
|
||||
write_case("advert_sensor", {
|
||||
"description": "ADVERT flood, sensor node type",
|
||||
"tags": ["advert", "flood", "sensor"],
|
||||
"packet_hex": advert_packet(bytes([0x84]) + b"TempSensor").hex(),
|
||||
"valid": True,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ADVERT",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": 1, "hops": []},
|
||||
"payload": {
|
||||
"pubkey_prefix": DUMMY_PUBKEY[:4].hex(),
|
||||
"timestamp": 1774600000,
|
||||
"advert": {
|
||||
"flags": 0x84,
|
||||
"node_type": "sensor",
|
||||
"location": None,
|
||||
"name": "TempSensor"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# ADVERT: with path (4 hops, 1-byte hash)
|
||||
write_case("advert_with_path", {
|
||||
"description": "ADVERT flood, 4 hops with 1-byte hashes",
|
||||
"tags": ["advert", "flood", "path"],
|
||||
"packet_hex": advert_packet(
|
||||
bytes([0x81]) + b"RelayedNode",
|
||||
path_hops=[b"\x2a", b"\x3d", b"\x35", b"\xed"]
|
||||
).hex(),
|
||||
"valid": True,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ADVERT",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": 1, "hops": ["2a", "3d", "35", "ed"]},
|
||||
"payload": {
|
||||
"pubkey_prefix": DUMMY_PUBKEY[:4].hex(),
|
||||
"timestamp": 1774600000,
|
||||
"advert": {
|
||||
"flags": 0x81,
|
||||
"node_type": "chat",
|
||||
"location": None,
|
||||
"name": "RelayedNode"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# ACK: basic
|
||||
write_case("ack_basic", {
|
||||
"description": "ACK flood, simple acknowledgment",
|
||||
"tags": ["ack", "flood"],
|
||||
"packet_hex": ack_packet(0xDEADBEEF).hex(),
|
||||
"valid": True,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ACK",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": 1, "hops": []},
|
||||
"payload": {"ack_crc": 0xDEADBEEF}
|
||||
}
|
||||
})
|
||||
|
||||
# ACK: with 1-byte hash path
|
||||
write_case("ack_with_path", {
|
||||
"description": "ACK flood, 2 hops with 1-byte hashes",
|
||||
"tags": ["ack", "flood", "path"],
|
||||
"packet_hex": ack_packet(
|
||||
0x008DC29F, path_hops=[b"\xd1", b"\xac"]
|
||||
).hex(),
|
||||
"valid": True,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ACK",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": 1, "hops": ["d1", "ac"]},
|
||||
"payload": {"ack_crc": 0x008DC29F}
|
||||
}
|
||||
})
|
||||
|
||||
# TXT_MSG: direct, 2-byte hashes
|
||||
write_case("txt_msg_direct_2b_hash", {
|
||||
"description": "TXT_MSG direct, 2-byte hashes, 2 hops",
|
||||
"tags": ["txt_msg", "direct", "2byte_hash", "encrypted"],
|
||||
"packet_hex": peer_packet(
|
||||
0x02, 2, 0x5b, 0x43, b"\xab\xcd", bytes(48),
|
||||
path_hops=[b"\xac\x98", b"\xd1\x4d"], hash_size=2
|
||||
).hex(),
|
||||
"valid": True,
|
||||
"expected": {
|
||||
"route_type": "direct",
|
||||
"payload_type": "TXT_MSG",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": 2, "hops": ["ac98", "d14d"]},
|
||||
"payload": {"dst": "5b", "src": "43", "mac": "abcd", "ciphertext_len": 48}
|
||||
}
|
||||
})
|
||||
|
||||
# TXT_MSG: flood with loops (duplicate hops)
|
||||
write_case("txt_msg_flood_with_loops", {
|
||||
"description": "TXT_MSG flood with routing loop (ac98 appears 3x)",
|
||||
"tags": ["txt_msg", "flood", "loop", "2byte_hash"],
|
||||
"packet_hex": peer_packet(
|
||||
0x02, 1, 0x5b, 0x43, b"\xab\xcd", bytes(32),
|
||||
path_hops=[b"\xac\x98", b"\xd1\x4d", b"\xac\x98", b"\xff\x01", b"\xac\x98"],
|
||||
hash_size=2
|
||||
).hex(),
|
||||
"valid": True,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "TXT_MSG",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": 2, "hops": ["ac98", "d14d", "ac98", "ff01", "ac98"]},
|
||||
"payload": {"dst": "5b", "src": "43", "mac": "abcd", "ciphertext_len": 32}
|
||||
}
|
||||
})
|
||||
|
||||
# REQ: with transport codes (transport direct)
|
||||
write_case("req_transport_direct", {
|
||||
"description": "REQ transport-direct with transport codes and 1-byte path",
|
||||
"tags": ["req", "tdirect", "transport_codes", "encrypted"],
|
||||
"packet_hex": peer_packet(
|
||||
0x00, 3, 0xa1, 0xb2, b"\xef\x01", bytes(32),
|
||||
path_hops=[b"\xcc", b"\xdd"], hash_size=1,
|
||||
tc=(0x1234, 0x5678)
|
||||
).hex(),
|
||||
"valid": True,
|
||||
"expected": {
|
||||
"route_type": "tdirect",
|
||||
"payload_type": "REQ",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": {"tc1": 0x1234, "tc2": 0x5678},
|
||||
"path": {"hash_size": 1, "hops": ["cc", "dd"]},
|
||||
"payload": {"dst": "a1", "src": "b2", "mac": "ef01", "ciphertext_len": 32}
|
||||
}
|
||||
})
|
||||
|
||||
# ANON_REQ: route_type=3 but no actual TC (older firmware, TC fallback)
|
||||
ephem = bytes(range(0x20, 0x40)) # 32-byte ephemeral pubkey
|
||||
write_case("anon_req_tc_fallback", {
|
||||
"description": "ANON_REQ tdirect header but NO transport codes (older firmware)",
|
||||
"tags": ["anon_req", "tdirect", "tc_fallback"],
|
||||
"packet_hex": (
|
||||
bytes([mc_header(0x07, 3)])
|
||||
+ bytes([path_len_byte(3, 1)]) + b"\x13\x35\x42"
|
||||
+ bytes([0xd9]) + ephem + b"\xb2\xe2" + bytes(48)
|
||||
).hex(),
|
||||
"valid": True,
|
||||
"expected": {
|
||||
"route_type": "tdirect",
|
||||
"payload_type": "ANON_REQ",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": 1, "hops": ["13", "35", "42"]},
|
||||
"payload": {
|
||||
"dst": "d9",
|
||||
"ephem_prefix": ephem[:4].hex(),
|
||||
"mac": "b2e2",
|
||||
"ciphertext_len": 48
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# GRP_TXT: basic group text
|
||||
write_case("grp_txt_basic", {
|
||||
"description": "GRP_TXT flood, group text message",
|
||||
"tags": ["grp_txt", "flood", "group", "encrypted"],
|
||||
"packet_hex": group_packet(0x05, 0xCC, b"\xab\xcd", bytes(32)).hex(),
|
||||
"valid": True,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "GRP_TXT",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": 1, "hops": []},
|
||||
"payload": {"channel_hash": "cc", "mac": "abcd", "ciphertext_len": 32}
|
||||
}
|
||||
})
|
||||
|
||||
# TRACE: basic trace packet
|
||||
write_case("trace_basic", {
|
||||
"description": "TRACE flood with tag, auth, and 2 trace hops",
|
||||
"tags": ["trace", "flood"],
|
||||
"packet_hex": trace_packet(
|
||||
0x12345678, 0xAABBCCDD, 0x00, b"\x11\x22"
|
||||
).hex(),
|
||||
"valid": True,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "TRACE",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": 1, "hops": []},
|
||||
"payload": {"trace_tag": 0x12345678, "trace_hops": 2}
|
||||
}
|
||||
})
|
||||
|
||||
# MULTIPART: ACK inner type
|
||||
write_case("multipart_ack", {
|
||||
"description": "MULTIPART flood, remaining=3, inner type=ACK",
|
||||
"tags": ["multipart", "flood", "ack"],
|
||||
"packet_hex": multipart_packet(
|
||||
3, 0x03, struct.pack("<I", 0xCAFEBABE)
|
||||
).hex(),
|
||||
"valid": True,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "MULTIPART",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": 1, "hops": []},
|
||||
"payload": {"multipart_remaining": 3, "multipart_inner": "ACK"}
|
||||
}
|
||||
})
|
||||
|
||||
# CONTROL: DISCOVER_REQ
|
||||
write_case("control_discover_req", {
|
||||
"description": "CONTROL flood, DISCOVER_REQ sub-type",
|
||||
"tags": ["control", "flood", "discover"],
|
||||
"packet_hex": control_packet(
|
||||
0x80, bytes([0x03]) + struct.pack("<I", 0xABCD1234)
|
||||
).hex(),
|
||||
"valid": True,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "CONTROL",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": 1, "hops": []},
|
||||
"payload": {"control_subtype": "DISCOVER_REQ"}
|
||||
}
|
||||
})
|
||||
|
||||
# PATH: 3-byte hash, 1 hop
|
||||
write_case("path_3byte_hash", {
|
||||
"description": "PATH flood, 1 hop with 3-byte hash",
|
||||
"tags": ["path", "flood", "3byte_hash", "encrypted"],
|
||||
"packet_hex": peer_packet(
|
||||
0x08, 1, 0xe6, 0xe9, b"\x2e\x47", bytes(46),
|
||||
path_hops=[b"\x1a\xff\xf3"], hash_size=3
|
||||
).hex(),
|
||||
"valid": True,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "PATH",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": 3, "hops": ["1afff3"]},
|
||||
"payload": {"dst": "e6", "src": "e9", "mac": "2e47", "ciphertext_len": 46}
|
||||
}
|
||||
})
|
||||
|
||||
# PATH: 4-byte hash, 1 hop
|
||||
write_case("path_4byte_hash", {
|
||||
"description": "PATH flood, 1 hop with 4-byte hash",
|
||||
"tags": ["path", "flood", "4byte_hash", "encrypted"],
|
||||
"packet_hex": peer_packet(
|
||||
0x08, 1, 0xe6, 0xe9, b"\x2e\x47", bytes(16),
|
||||
path_hops=[b"\xaa\xbb\xcc\xdd"], hash_size=4
|
||||
).hex(),
|
||||
"valid": True,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "PATH",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": 4, "hops": ["aabbccdd"]},
|
||||
"payload": {"dst": "e6", "src": "e9", "mac": "2e47", "ciphertext_len": 16}
|
||||
}
|
||||
})
|
||||
|
||||
# ── Invalid packets ───────────────────────────────────────────────
|
||||
|
||||
# Too short: 1 byte
|
||||
write_case("too_short", {
|
||||
"description": "Packet too short (1 byte)",
|
||||
"tags": ["invalid", "too_short"],
|
||||
"packet_hex": "11",
|
||||
"valid": False,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ADVERT",
|
||||
"error": "too_short"
|
||||
}
|
||||
})
|
||||
|
||||
# Bad path_len: claims 33 hops but only 24 bytes remain
|
||||
bad_pkt = bytes([mc_header(0x02, 1), 0xe1]) + bytes(24)
|
||||
write_case("bad_path_len", {
|
||||
"description": "TXT_MSG flood, path_len claims 33 hops (4B hash) but data is too short",
|
||||
"tags": ["invalid", "bad_framing", "path"],
|
||||
"packet_hex": bad_pkt.hex(),
|
||||
"valid": False,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "TXT_MSG",
|
||||
"error": "bad_framing"
|
||||
}
|
||||
})
|
||||
|
||||
# Noise: random bytes that don't parse as meshcore
|
||||
write_case("noise_packet", {
|
||||
"description": "Random bytes, not a valid MeshCore packet",
|
||||
"tags": ["invalid", "noise"],
|
||||
"packet_hex": "03d135839fc28d00",
|
||||
"valid": False,
|
||||
"expected": {
|
||||
"route_type": "tdirect",
|
||||
"payload_type": "REQ",
|
||||
"error": "not_meshcore"
|
||||
}
|
||||
})
|
||||
|
||||
# Empty packet
|
||||
write_case("empty_packet", {
|
||||
"description": "Empty packet (0 bytes)",
|
||||
"tags": ["invalid", "too_short"],
|
||||
"packet_hex": "",
|
||||
"valid": False,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "REQ",
|
||||
"error": "too_short"
|
||||
}
|
||||
})
|
||||
|
||||
# Bad advert: too short for pubkey+timestamp+signature
|
||||
short_advert = advert_packet(b"") # 102 bytes: hdr + path_len + 100 payload
|
||||
truncated = short_advert[:50] # cut in half
|
||||
write_case("advert_truncated", {
|
||||
"description": "ADVERT flood, truncated mid-signature (50 bytes)",
|
||||
"tags": ["invalid", "advert", "truncated"],
|
||||
"packet_hex": truncated.hex(),
|
||||
"valid": True, # parses as ADVERT but payload is bad
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ADVERT",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": 1, "hops": []}
|
||||
}
|
||||
})
|
||||
|
||||
print(f"\nDone! {len(list(OUT.glob('*.json')))} test vectors written to {OUT}/")
|
||||
Executable
+327
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate MeshCore test vectors using Hypothesis (property-based testing)."""
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["hypothesis", "jsonschema"]
|
||||
# ///
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hypothesis import given, settings, HealthCheck, Phase
|
||||
from hypothesis import strategies as st
|
||||
|
||||
CORPUS_DIR = Path(__file__).parent
|
||||
OUT = CORPUS_DIR / "generated"
|
||||
SCHEMA_PATH = CORPUS_DIR / "schema.json"
|
||||
|
||||
# MeshCore constants
|
||||
MAX_PAYLOAD = 184
|
||||
MAX_PATH_SIZE = 64
|
||||
MAX_ADVERT_DATA_SIZE = 32
|
||||
PUB_KEY_SIZE = 32
|
||||
SIGNATURE_SIZE = 64
|
||||
|
||||
ROUTE_TYPES = {0: "tflood", 1: "flood", 2: "direct", 3: "tdirect"}
|
||||
PAYLOAD_TYPES = {
|
||||
0: "REQ", 1: "RESPONSE", 2: "TXT_MSG", 3: "ACK", 4: "ADVERT",
|
||||
5: "GRP_TXT", 6: "GRP_DATA", 7: "ANON_REQ", 8: "PATH",
|
||||
9: "TRACE", 10: "MULTIPART", 11: "CONTROL", 15: "RAW_CUSTOM",
|
||||
}
|
||||
NODE_TYPES = {1: "chat", 2: "repeater", 3: "room", 4: "sensor"}
|
||||
|
||||
|
||||
def mc_header(payload_type: int, route_type: int, ver: int = 0) -> int:
|
||||
return (ver << 6) | (payload_type << 2) | route_type
|
||||
|
||||
|
||||
def path_len_byte(hop_count: int, hash_size: int) -> int:
|
||||
return ((hash_size - 1) << 6) | (hop_count & 0x3F)
|
||||
|
||||
|
||||
# ── Hypothesis strategies ─────────────────────────────────────────
|
||||
|
||||
hash_sizes = st.sampled_from([1, 2, 3])
|
||||
route_types_no_tc = st.sampled_from([1, 2]) # flood, direct (no transport codes)
|
||||
route_types_all = st.sampled_from([0, 1, 2, 3])
|
||||
payload_vers = st.sampled_from([0]) # only v0 is well-defined
|
||||
|
||||
|
||||
@st.composite
|
||||
def path_strategy(draw, hash_size=None):
|
||||
hs = hash_size or draw(hash_sizes)
|
||||
max_hops = min(63, MAX_PATH_SIZE // hs)
|
||||
hop_count = draw(st.integers(min_value=0, max_value=min(max_hops, 10)))
|
||||
hops = [draw(st.binary(min_size=hs, max_size=hs)) for _ in range(hop_count)]
|
||||
return hs, hops
|
||||
|
||||
|
||||
@st.composite
|
||||
def advert_appdata(draw):
|
||||
"""Generate valid ADVERT app_data."""
|
||||
node_type = draw(st.sampled_from([1, 2, 3, 4]))
|
||||
has_loc = draw(st.booleans())
|
||||
has_name = draw(st.booleans())
|
||||
flags = node_type
|
||||
data = b""
|
||||
loc = None
|
||||
name = None
|
||||
|
||||
if has_loc:
|
||||
flags |= 0x10
|
||||
lat = draw(st.integers(min_value=-90_000_000, max_value=90_000_000))
|
||||
lon = draw(st.integers(min_value=-180_000_000, max_value=180_000_000))
|
||||
data += struct.pack("<ii", lat, lon)
|
||||
loc = {"lat": round(lat / 1e6, 4), "lon": round(lon / 1e6, 4)}
|
||||
|
||||
if has_name:
|
||||
flags |= 0x80
|
||||
# Generate ASCII names (to avoid UTF-8 scoring ambiguity in generated tests)
|
||||
name = draw(st.text(
|
||||
alphabet=st.characters(whitelist_categories=("L", "N", "P", "Z"),
|
||||
max_codepoint=127),
|
||||
min_size=1, max_size=20
|
||||
))
|
||||
name_bytes = name.encode("ascii")
|
||||
data += name_bytes
|
||||
|
||||
if len(data) + 1 > MAX_ADVERT_DATA_SIZE:
|
||||
data = data[:MAX_ADVERT_DATA_SIZE - 1]
|
||||
if name:
|
||||
name = data[8 if has_loc else 0:].decode("ascii", errors="replace")
|
||||
|
||||
app_data = bytes([flags]) + data
|
||||
node_label = NODE_TYPES.get(node_type, f"type={node_type}")
|
||||
return app_data, {"flags": flags, "node_type": node_label, "location": loc, "name": name}
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_advert_packet(draw):
|
||||
"""Generate a complete valid ADVERT packet with expected decode."""
|
||||
route_type = draw(route_types_no_tc)
|
||||
ver = draw(payload_vers)
|
||||
hs, hops = draw(path_strategy())
|
||||
pubkey = draw(st.binary(min_size=PUB_KEY_SIZE, max_size=PUB_KEY_SIZE))
|
||||
timestamp = draw(st.integers(min_value=0, max_value=0xFFFFFFFF))
|
||||
signature = draw(st.binary(min_size=SIGNATURE_SIZE, max_size=SIGNATURE_SIZE))
|
||||
app_data, advert_expected = draw(advert_appdata())
|
||||
|
||||
hdr = bytes([mc_header(0x04, route_type, ver)])
|
||||
pl = bytes([path_len_byte(len(hops), hs)])
|
||||
path = b"".join(hops)
|
||||
payload = pubkey + struct.pack("<I", timestamp) + signature + app_data
|
||||
packet = hdr + pl + path + payload
|
||||
|
||||
return packet, {
|
||||
"route_type": ROUTE_TYPES[route_type],
|
||||
"payload_type": "ADVERT",
|
||||
"payload_ver": ver,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": hs, "hops": [h.hex() for h in hops]},
|
||||
"payload": {
|
||||
"pubkey_prefix": pubkey[:4].hex(),
|
||||
"timestamp": timestamp,
|
||||
"advert": advert_expected,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_ack_packet(draw):
|
||||
route_type = draw(route_types_no_tc)
|
||||
hs, hops = draw(path_strategy())
|
||||
crc = draw(st.integers(min_value=0, max_value=0xFFFFFFFF))
|
||||
|
||||
hdr = bytes([mc_header(0x03, route_type)])
|
||||
pl = bytes([path_len_byte(len(hops), hs)])
|
||||
path = b"".join(hops)
|
||||
packet = hdr + pl + path + struct.pack("<I", crc)
|
||||
|
||||
return packet, {
|
||||
"route_type": ROUTE_TYPES[route_type],
|
||||
"payload_type": "ACK",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": hs, "hops": [h.hex() for h in hops]},
|
||||
"payload": {"ack_crc": crc}
|
||||
}
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_encrypted_peer_packet(draw):
|
||||
ptype = draw(st.sampled_from([0, 1, 2, 8])) # REQ/RESPONSE/TXT_MSG/PATH
|
||||
route_type = draw(route_types_no_tc)
|
||||
hs, hops = draw(path_strategy())
|
||||
dst = draw(st.integers(min_value=0, max_value=255))
|
||||
src = draw(st.integers(min_value=0, max_value=255))
|
||||
mac = draw(st.binary(min_size=2, max_size=2))
|
||||
ct_len = draw(st.integers(min_value=0, max_value=64))
|
||||
ct = draw(st.binary(min_size=ct_len, max_size=ct_len))
|
||||
|
||||
hdr = bytes([mc_header(ptype, route_type)])
|
||||
pl = bytes([path_len_byte(len(hops), hs)])
|
||||
path = b"".join(hops)
|
||||
packet = hdr + pl + path + bytes([dst, src]) + mac + ct
|
||||
|
||||
return packet, {
|
||||
"route_type": ROUTE_TYPES[route_type],
|
||||
"payload_type": PAYLOAD_TYPES[ptype],
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": hs, "hops": [h.hex() for h in hops]},
|
||||
"payload": {
|
||||
"dst": f"{dst:02x}",
|
||||
"src": f"{src:02x}",
|
||||
"mac": mac.hex(),
|
||||
"ciphertext_len": ct_len
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_group_packet(draw):
|
||||
ptype = draw(st.sampled_from([5, 6])) # GRP_TXT/GRP_DATA
|
||||
route_type = draw(route_types_no_tc)
|
||||
hs, hops = draw(path_strategy())
|
||||
ch = draw(st.integers(min_value=0, max_value=255))
|
||||
mac = draw(st.binary(min_size=2, max_size=2))
|
||||
ct_len = draw(st.integers(min_value=0, max_value=64))
|
||||
ct = draw(st.binary(min_size=ct_len, max_size=ct_len))
|
||||
|
||||
hdr = bytes([mc_header(ptype, route_type)])
|
||||
pl = bytes([path_len_byte(len(hops), hs)])
|
||||
path = b"".join(hops)
|
||||
packet = hdr + pl + path + bytes([ch]) + mac + ct
|
||||
|
||||
return packet, {
|
||||
"route_type": ROUTE_TYPES[route_type],
|
||||
"payload_type": PAYLOAD_TYPES[ptype],
|
||||
"payload_ver": 0,
|
||||
"transport_codes": None,
|
||||
"path": {"hash_size": hs, "hops": [h.hex() for h in hops]},
|
||||
"payload": {
|
||||
"channel_hash": f"{ch:02x}",
|
||||
"mac": mac.hex(),
|
||||
"ciphertext_len": ct_len
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@st.composite
|
||||
def invalid_packet(draw):
|
||||
"""Generate an invalid MeshCore packet via mutation."""
|
||||
mutation = draw(st.sampled_from(["truncate", "bad_path_len", "random"]))
|
||||
|
||||
if mutation == "truncate":
|
||||
# Valid packet truncated to random length
|
||||
pkt, _ = draw(valid_ack_packet())
|
||||
cut = draw(st.integers(min_value=0, max_value=max(0, len(pkt) - 2)))
|
||||
packet = pkt[:cut]
|
||||
if len(packet) < 2:
|
||||
error = "too_short"
|
||||
else:
|
||||
error = "bad_framing"
|
||||
elif mutation == "bad_path_len":
|
||||
# Header + impossible path_len
|
||||
route_type = draw(route_types_no_tc)
|
||||
ptype = draw(st.sampled_from(list(PAYLOAD_TYPES.keys())))
|
||||
hdr = bytes([mc_header(ptype, route_type)])
|
||||
# path_len claiming huge path
|
||||
bad_pl = draw(st.integers(min_value=0xC0, max_value=0xFF))
|
||||
tail = draw(st.binary(min_size=1, max_size=20))
|
||||
packet = hdr + bytes([bad_pl]) + tail
|
||||
error = "bad_framing"
|
||||
else: # random
|
||||
packet = draw(st.binary(min_size=2, max_size=50))
|
||||
error = "not_meshcore"
|
||||
|
||||
header = packet[0] if packet else 0
|
||||
route_type = header & 0x03
|
||||
payload_type = (header >> 2) & 0x0F
|
||||
|
||||
return packet, {
|
||||
"route_type": ROUTE_TYPES.get(route_type, f"rt{route_type}"),
|
||||
"payload_type": PAYLOAD_TYPES.get(payload_type, f"0x{payload_type:02x}"),
|
||||
"error": error if len(packet) < 2 else error
|
||||
}
|
||||
|
||||
|
||||
# ── Generation ────────────────────────────────────────────────────
|
||||
|
||||
_counter = 0
|
||||
|
||||
|
||||
def save_case(packet: bytes, expected: dict, valid: bool, prefix: str):
|
||||
global _counter
|
||||
_counter += 1
|
||||
ptype = expected["payload_type"].lower()
|
||||
name = f"{prefix}_{ptype}_{_counter:04d}"
|
||||
|
||||
tags = [ptype, expected["route_type"]]
|
||||
if not valid:
|
||||
tags.append("invalid")
|
||||
if "error" in expected:
|
||||
tags.append(expected["error"])
|
||||
|
||||
case = {
|
||||
"description": f"Generated {prefix}: {expected['payload_type']} {expected['route_type']}",
|
||||
"tags": tags,
|
||||
"packet_hex": packet.hex(),
|
||||
"valid": valid,
|
||||
"expected": expected,
|
||||
}
|
||||
|
||||
# Validate against schema
|
||||
import jsonschema
|
||||
schema = json.loads(SCHEMA_PATH.read_text())
|
||||
try:
|
||||
jsonschema.validate(case, schema)
|
||||
except jsonschema.ValidationError as e:
|
||||
print(f" SKIP {name}: schema error: {e.message}")
|
||||
return
|
||||
|
||||
(OUT / f"{name}.json").write_text(json.dumps(case, indent=2, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def generate(count: int):
|
||||
OUT.mkdir(exist_ok=True)
|
||||
|
||||
# Clear previous generated files
|
||||
for f in OUT.glob("*.json"):
|
||||
f.unlink()
|
||||
|
||||
global _counter
|
||||
_counter = 0
|
||||
|
||||
strategies = [
|
||||
("valid", valid_advert_packet(), True),
|
||||
("valid", valid_ack_packet(), True),
|
||||
("valid", valid_encrypted_peer_packet(), True),
|
||||
("valid", valid_group_packet(), True),
|
||||
("invalid", invalid_packet(), False),
|
||||
]
|
||||
|
||||
per_strategy = max(1, count // len(strategies))
|
||||
|
||||
for prefix, strategy, valid in strategies:
|
||||
for i in range(per_strategy):
|
||||
example = strategy.example()
|
||||
packet, expected = example
|
||||
save_case(packet, expected, valid, prefix)
|
||||
|
||||
total = len(list(OUT.glob("*.json")))
|
||||
print(f"Generated {total} test vectors in {OUT}/")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate MeshCore test vectors")
|
||||
parser.add_argument("--count", type=int, default=50, help="Total test cases to generate")
|
||||
args = parser.parse_args()
|
||||
generate(args.count)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"description": "ACK flood, simple acknowledgment",
|
||||
"tags": [
|
||||
"ack",
|
||||
"flood"
|
||||
],
|
||||
"packet_hex": "0d00efbeadde",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ACK",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": null,
|
||||
"path": {
|
||||
"hash_size": 1,
|
||||
"hops": []
|
||||
},
|
||||
"payload": {
|
||||
"ack_crc": 3735928559
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"description": "ACK flood, 2 hops with 1-byte hashes",
|
||||
"tags": [
|
||||
"ack",
|
||||
"flood",
|
||||
"path"
|
||||
],
|
||||
"packet_hex": "0d02d1ac9fc28d00",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ACK",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": null,
|
||||
"path": {
|
||||
"hash_size": 1,
|
||||
"hops": [
|
||||
"d1",
|
||||
"ac"
|
||||
]
|
||||
},
|
||||
"payload": {
|
||||
"ack_crc": 9290399
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"description": "ADVERT flood, chat node with simple ASCII name",
|
||||
"tags": [
|
||||
"advert",
|
||||
"flood",
|
||||
"name",
|
||||
"chat"
|
||||
],
|
||||
"packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc6690000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000081546573744e6f6465",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ADVERT",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": null,
|
||||
"path": {
|
||||
"hash_size": 1,
|
||||
"hops": []
|
||||
},
|
||||
"payload": {
|
||||
"pubkey_prefix": "00010203",
|
||||
"timestamp": 1774600000,
|
||||
"advert": {
|
||||
"flags": 129,
|
||||
"node_type": "chat",
|
||||
"location": null,
|
||||
"name": "TestNode"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"description": "ADVERT flood, chat node with emoji in name (multi-byte UTF-8)",
|
||||
"tags": [
|
||||
"advert",
|
||||
"flood",
|
||||
"emoji",
|
||||
"name",
|
||||
"utf8"
|
||||
],
|
||||
"packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc6690000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000081f09fa68520526170746f72",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ADVERT",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": null,
|
||||
"path": {
|
||||
"hash_size": 1,
|
||||
"hops": []
|
||||
},
|
||||
"payload": {
|
||||
"pubkey_prefix": "00010203",
|
||||
"timestamp": 1774600000,
|
||||
"advert": {
|
||||
"flags": 129,
|
||||
"node_type": "chat",
|
||||
"location": null,
|
||||
"name": "🦅 Raptor"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"description": "ADVERT flood, feat1+feat2 bits set but bytes are actually the name",
|
||||
"tags": [
|
||||
"advert",
|
||||
"flood",
|
||||
"feat",
|
||||
"scoring",
|
||||
"emoji"
|
||||
],
|
||||
"packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc66900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e1f09f8f94efb88f204d746e4e6f6465",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ADVERT",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": null,
|
||||
"path": {
|
||||
"hash_size": 1,
|
||||
"hops": []
|
||||
},
|
||||
"payload": {
|
||||
"pubkey_prefix": "00010203",
|
||||
"timestamp": 1774600000,
|
||||
"advert": {
|
||||
"flags": 225,
|
||||
"node_type": "chat",
|
||||
"location": null,
|
||||
"name": "🏔️ MtnNode"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"description": "ADVERT flood, repeater with valid GPS coords and name",
|
||||
"tags": [
|
||||
"advert",
|
||||
"flood",
|
||||
"location",
|
||||
"repeater"
|
||||
],
|
||||
"packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc6690000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000092346640023807b4f848696c6c746f7052707472",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ADVERT",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": null,
|
||||
"path": {
|
||||
"hash_size": 1,
|
||||
"hops": []
|
||||
},
|
||||
"payload": {
|
||||
"pubkey_prefix": "00010203",
|
||||
"timestamp": 1774600000,
|
||||
"advert": {
|
||||
"flags": 146,
|
||||
"node_type": "repeater",
|
||||
"location": {
|
||||
"lat": 37.7749,
|
||||
"lon": -122.4194
|
||||
},
|
||||
"name": "HilltopRptr"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"description": "ADVERT flood, bare minimum (no app_data)",
|
||||
"tags": [
|
||||
"advert",
|
||||
"flood",
|
||||
"minimal"
|
||||
],
|
||||
"packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc66900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ADVERT",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": null,
|
||||
"path": {
|
||||
"hash_size": 1,
|
||||
"hops": []
|
||||
},
|
||||
"payload": {
|
||||
"pubkey_prefix": "00010203",
|
||||
"timestamp": 1774600000,
|
||||
"advert": null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"description": "ADVERT flood, sensor node type",
|
||||
"tags": [
|
||||
"advert",
|
||||
"flood",
|
||||
"sensor"
|
||||
],
|
||||
"packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc669000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008454656d7053656e736f72",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ADVERT",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": null,
|
||||
"path": {
|
||||
"hash_size": 1,
|
||||
"hops": []
|
||||
},
|
||||
"payload": {
|
||||
"pubkey_prefix": "00010203",
|
||||
"timestamp": 1774600000,
|
||||
"advert": {
|
||||
"flags": 132,
|
||||
"node_type": "sensor",
|
||||
"location": null,
|
||||
"name": "TempSensor"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"description": "ADVERT flood, truncated mid-signature (50 bytes)",
|
||||
"tags": [
|
||||
"invalid",
|
||||
"advert",
|
||||
"truncated"
|
||||
],
|
||||
"packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc669000000000000000000000000",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ADVERT",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": null,
|
||||
"path": {
|
||||
"hash_size": 1,
|
||||
"hops": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"description": "ADVERT flood, 4 hops with 1-byte hashes",
|
||||
"tags": [
|
||||
"advert",
|
||||
"flood",
|
||||
"path"
|
||||
],
|
||||
"packet_hex": "11042a3d35ed000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc669000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008152656c617965644e6f6465",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ADVERT",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": null,
|
||||
"path": {
|
||||
"hash_size": 1,
|
||||
"hops": [
|
||||
"2a",
|
||||
"3d",
|
||||
"35",
|
||||
"ed"
|
||||
]
|
||||
},
|
||||
"payload": {
|
||||
"pubkey_prefix": "00010203",
|
||||
"timestamp": 1774600000,
|
||||
"advert": {
|
||||
"flags": 129,
|
||||
"node_type": "chat",
|
||||
"location": null,
|
||||
"name": "RelayedNode"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"description": "ANON_REQ tdirect header but NO transport codes (older firmware)",
|
||||
"tags": [
|
||||
"anon_req",
|
||||
"tdirect",
|
||||
"tc_fallback"
|
||||
],
|
||||
"packet_hex": "1f03133542d9202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3fb2e2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "tdirect",
|
||||
"payload_type": "ANON_REQ",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": null,
|
||||
"path": {
|
||||
"hash_size": 1,
|
||||
"hops": [
|
||||
"13",
|
||||
"35",
|
||||
"42"
|
||||
]
|
||||
},
|
||||
"payload": {
|
||||
"dst": "d9",
|
||||
"ephem_prefix": "20212223",
|
||||
"mac": "b2e2",
|
||||
"ciphertext_len": 48
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"description": "TXT_MSG flood, path_len claims 33 hops (4B hash) but data is too short",
|
||||
"tags": [
|
||||
"invalid",
|
||||
"bad_framing",
|
||||
"path"
|
||||
],
|
||||
"packet_hex": "09e1000000000000000000000000000000000000000000000000",
|
||||
"valid": false,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "TXT_MSG",
|
||||
"error": "bad_framing"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"description": "CONTROL flood, DISCOVER_REQ sub-type",
|
||||
"tags": [
|
||||
"control",
|
||||
"flood",
|
||||
"discover"
|
||||
],
|
||||
"packet_hex": "2d0080033412cdab",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "CONTROL",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": null,
|
||||
"path": {
|
||||
"hash_size": 1,
|
||||
"hops": []
|
||||
},
|
||||
"payload": {
|
||||
"control_subtype": "DISCOVER_REQ"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"description": "Empty packet (0 bytes)",
|
||||
"tags": [
|
||||
"invalid",
|
||||
"too_short"
|
||||
],
|
||||
"packet_hex": "",
|
||||
"valid": false,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "REQ",
|
||||
"error": "too_short"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"description": "GRP_TXT flood, group text message",
|
||||
"tags": [
|
||||
"grp_txt",
|
||||
"flood",
|
||||
"group",
|
||||
"encrypted"
|
||||
],
|
||||
"packet_hex": "1500ccabcd0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "GRP_TXT",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": null,
|
||||
"path": {
|
||||
"hash_size": 1,
|
||||
"hops": []
|
||||
},
|
||||
"payload": {
|
||||
"channel_hash": "cc",
|
||||
"mac": "abcd",
|
||||
"ciphertext_len": 32
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"description": "MULTIPART flood, remaining=3, inner type=ACK",
|
||||
"tags": [
|
||||
"multipart",
|
||||
"flood",
|
||||
"ack"
|
||||
],
|
||||
"packet_hex": "290033bebafeca",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "MULTIPART",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": null,
|
||||
"path": {
|
||||
"hash_size": 1,
|
||||
"hops": []
|
||||
},
|
||||
"payload": {
|
||||
"multipart_remaining": 3,
|
||||
"multipart_inner": "ACK"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"description": "Random bytes, not a valid MeshCore packet",
|
||||
"tags": [
|
||||
"invalid",
|
||||
"noise"
|
||||
],
|
||||
"packet_hex": "03d135839fc28d00",
|
||||
"valid": false,
|
||||
"expected": {
|
||||
"route_type": "tdirect",
|
||||
"payload_type": "REQ",
|
||||
"error": "not_meshcore"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"description": "PATH flood, 1 hop with 3-byte hash",
|
||||
"tags": [
|
||||
"path",
|
||||
"flood",
|
||||
"3byte_hash",
|
||||
"encrypted"
|
||||
],
|
||||
"packet_hex": "21811afff3e6e92e4700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "PATH",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": null,
|
||||
"path": {
|
||||
"hash_size": 3,
|
||||
"hops": [
|
||||
"1afff3"
|
||||
]
|
||||
},
|
||||
"payload": {
|
||||
"dst": "e6",
|
||||
"src": "e9",
|
||||
"mac": "2e47",
|
||||
"ciphertext_len": 46
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"description": "PATH flood, 1 hop with 4-byte hash",
|
||||
"tags": [
|
||||
"path",
|
||||
"flood",
|
||||
"4byte_hash",
|
||||
"encrypted"
|
||||
],
|
||||
"packet_hex": "21c1aabbccdde6e92e4700000000000000000000000000000000",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "PATH",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": null,
|
||||
"path": {
|
||||
"hash_size": 4,
|
||||
"hops": [
|
||||
"aabbccdd"
|
||||
]
|
||||
},
|
||||
"payload": {
|
||||
"dst": "e6",
|
||||
"src": "e9",
|
||||
"mac": "2e47",
|
||||
"ciphertext_len": 16
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"description": "REQ transport-direct with transport codes and 1-byte path",
|
||||
"tags": [
|
||||
"req",
|
||||
"tdirect",
|
||||
"transport_codes",
|
||||
"encrypted"
|
||||
],
|
||||
"packet_hex": "033412785602ccdda1b2ef010000000000000000000000000000000000000000000000000000000000000000",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "tdirect",
|
||||
"payload_type": "REQ",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": {
|
||||
"tc1": 4660,
|
||||
"tc2": 22136
|
||||
},
|
||||
"path": {
|
||||
"hash_size": 1,
|
||||
"hops": [
|
||||
"cc",
|
||||
"dd"
|
||||
]
|
||||
},
|
||||
"payload": {
|
||||
"dst": "a1",
|
||||
"src": "b2",
|
||||
"mac": "ef01",
|
||||
"ciphertext_len": 32
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"description": "RESPONSE flood, bit-corrupted path_len (0xc7 should be small), LoRa bit error",
|
||||
"tags": ["invalid", "response", "flood", "corrupted", "real_capture", "bit_error"],
|
||||
"packet_hex": "05c735935ed17863f6ec7bbbd9bf8e5ea7d44b6a0e8f6318",
|
||||
"valid": false,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "RESPONSE",
|
||||
"error": "bad_framing"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"description": "RESPONSE flood, bit-corrupted path_len (0x32 should be 0x02 — bits 4,5 flipped), LoRa bit error",
|
||||
"tags": ["invalid", "response", "flood", "corrupted", "real_capture", "bit_error"],
|
||||
"packet_hex": "0532af1a5bd17863f6ec7bbbd9bfbe5ea7d44b6a0e8f6318",
|
||||
"valid": false,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "RESPONSE",
|
||||
"error": "bad_framing"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"description": "RESPONSE flood, 1 hop (real capture — same message as corrupted variants)",
|
||||
"tags": ["response", "flood", "path", "real_capture"],
|
||||
"packet_hex": "0501ac5bd17863f6ec7bbbd9bf8e5ea7d44b6a0e8f6318",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "RESPONSE",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": null,
|
||||
"path": {"hash_size": 1, "hops": ["ac"]},
|
||||
"payload": {"dst": "5b", "src": "d1", "mac": "7863", "ciphertext_len": 16}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"description": "RESPONSE flood, 2 hops [ac>ea] (real capture — same message as corrupted variants)",
|
||||
"tags": ["response", "flood", "path", "real_capture"],
|
||||
"packet_hex": "0502acea5bd17863f6ec7bbbd9bf8e5ea7d44b6a0e8f6318",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "RESPONSE",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": null,
|
||||
"path": {"hash_size": 1, "hops": ["ac", "ea"]},
|
||||
"payload": {"dst": "5b", "src": "d1", "mac": "7863", "ciphertext_len": 16}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"description": "Packet too short (1 byte)",
|
||||
"tags": [
|
||||
"invalid",
|
||||
"too_short"
|
||||
],
|
||||
"packet_hex": "11",
|
||||
"valid": false,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "ADVERT",
|
||||
"error": "too_short"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"description": "TRACE flood with tag, auth, and 2 trace hops",
|
||||
"tags": [
|
||||
"trace",
|
||||
"flood"
|
||||
],
|
||||
"packet_hex": "250078563412ddccbbaa001122",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "TRACE",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": null,
|
||||
"path": {
|
||||
"hash_size": 1,
|
||||
"hops": []
|
||||
},
|
||||
"payload": {
|
||||
"trace_tag": 305419896,
|
||||
"trace_hops": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"description": "TXT_MSG direct, 2-byte hashes, 2 hops",
|
||||
"tags": [
|
||||
"txt_msg",
|
||||
"direct",
|
||||
"2byte_hash",
|
||||
"encrypted"
|
||||
],
|
||||
"packet_hex": "0a42ac98d14d5b43abcd000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "direct",
|
||||
"payload_type": "TXT_MSG",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": null,
|
||||
"path": {
|
||||
"hash_size": 2,
|
||||
"hops": [
|
||||
"ac98",
|
||||
"d14d"
|
||||
]
|
||||
},
|
||||
"payload": {
|
||||
"dst": "5b",
|
||||
"src": "43",
|
||||
"mac": "abcd",
|
||||
"ciphertext_len": 48
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"description": "TXT_MSG flood with routing loop (ac98 appears 3x)",
|
||||
"tags": [
|
||||
"txt_msg",
|
||||
"flood",
|
||||
"loop",
|
||||
"2byte_hash"
|
||||
],
|
||||
"packet_hex": "0945ac98d14dac98ff01ac985b43abcd0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"valid": true,
|
||||
"expected": {
|
||||
"route_type": "flood",
|
||||
"payload_type": "TXT_MSG",
|
||||
"payload_ver": 0,
|
||||
"transport_codes": null,
|
||||
"path": {
|
||||
"hash_size": 2,
|
||||
"hops": [
|
||||
"ac98",
|
||||
"d14d",
|
||||
"ac98",
|
||||
"ff01",
|
||||
"ac98"
|
||||
]
|
||||
},
|
||||
"payload": {
|
||||
"dst": "5b",
|
||||
"src": "43",
|
||||
"mac": "abcd",
|
||||
"ciphertext_len": 32
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "MeshCore Packet Test Case",
|
||||
"description": "A single test vector for MeshCore packet decoders",
|
||||
"type": "object",
|
||||
"required": ["description", "tags", "packet_hex", "valid", "expected"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"minItems": 1
|
||||
},
|
||||
"packet_hex": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]*$",
|
||||
"description": "Raw MeshCore packet as lowercase hex (no 0x prefix)"
|
||||
},
|
||||
"valid": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this packet should parse successfully"
|
||||
},
|
||||
"expected": {
|
||||
"type": "object",
|
||||
"required": ["route_type", "payload_type"],
|
||||
"properties": {
|
||||
"route_type": {
|
||||
"type": "string",
|
||||
"enum": ["flood", "direct", "tflood", "tdirect"]
|
||||
},
|
||||
"payload_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"REQ", "RESPONSE", "TXT_MSG", "ACK", "ADVERT",
|
||||
"GRP_TXT", "GRP_DATA", "ANON_REQ", "PATH",
|
||||
"TRACE", "MULTIPART", "CONTROL", "RAW_CUSTOM"
|
||||
]
|
||||
},
|
||||
"payload_ver": { "type": "integer", "minimum": 0, "maximum": 3 },
|
||||
"transport_codes": {
|
||||
"oneOf": [
|
||||
{ "type": "null" },
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["tc1", "tc2"],
|
||||
"properties": {
|
||||
"tc1": { "type": "integer" },
|
||||
"tc2": { "type": "integer" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "object",
|
||||
"required": ["hash_size", "hops"],
|
||||
"properties": {
|
||||
"hash_size": { "type": "integer", "minimum": 1, "maximum": 4 },
|
||||
"hops": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "pattern": "^[0-9a-f]+$" }
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"error": {
|
||||
"type": "string",
|
||||
"enum": ["too_short", "not_meshcore", "bad_framing"],
|
||||
"description": "Expected error category for invalid packets"
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"description": "Payload-type-specific decoded fields",
|
||||
"properties": {
|
||||
"dst": { "type": "string", "pattern": "^[0-9a-f]{2}$" },
|
||||
"src": { "type": "string", "pattern": "^[0-9a-f]{2}$" },
|
||||
"mac": { "type": "string", "pattern": "^[0-9a-f]+$" },
|
||||
"ciphertext_len": { "type": "integer", "minimum": 0 },
|
||||
"channel_hash": { "type": "string", "pattern": "^[0-9a-f]{2}$" },
|
||||
"ack_crc": { "type": "integer" },
|
||||
"pubkey_prefix": { "type": "string", "pattern": "^[0-9a-f]+$" },
|
||||
"timestamp": { "type": "integer" },
|
||||
"advert": {
|
||||
"oneOf": [
|
||||
{ "type": "null" },
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"flags": { "type": "integer", "minimum": 0, "maximum": 255 },
|
||||
"node_type": { "type": "string" },
|
||||
"location": {
|
||||
"oneOf": [
|
||||
{ "type": "null" },
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["lat", "lon"],
|
||||
"properties": {
|
||||
"lat": { "type": "number" },
|
||||
"lon": { "type": "number" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": { "oneOf": [{ "type": "string" }, { "type": "null" }] }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"ephem_prefix": { "type": "string", "pattern": "^[0-9a-f]+$" },
|
||||
"trace_tag": { "type": "integer" },
|
||||
"trace_hops": { "type": "integer" },
|
||||
"multipart_remaining": { "type": "integer" },
|
||||
"multipart_inner": { "type": "string" },
|
||||
"control_subtype": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+207
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate MeshCore test corpus against schema and decoder."""
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["jsonschema", "cobs", "pyserial", "pycryptodome"]
|
||||
# ///
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import jsonschema
|
||||
|
||||
# Add meshcore/ directory to path so we can import the decoder
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from meshcore_rx import decode_meshcore_packet # noqa: E402
|
||||
|
||||
CORPUS_DIR = Path(__file__).parent
|
||||
SCHEMA_PATH = CORPUS_DIR / "schema.json"
|
||||
|
||||
# ANSI escape stripper
|
||||
_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
|
||||
|
||||
|
||||
def strip_ansi(s: str) -> str:
|
||||
return _ANSI_RE.sub("", s)
|
||||
|
||||
|
||||
def load_schema() -> dict:
|
||||
return json.loads(SCHEMA_PATH.read_text())
|
||||
|
||||
|
||||
def find_test_cases() -> list[Path]:
|
||||
cases = []
|
||||
for d in ["hand", "generated"]:
|
||||
p = CORPUS_DIR / d
|
||||
if p.is_dir():
|
||||
cases.extend(sorted(p.glob("*.json")))
|
||||
return cases
|
||||
|
||||
|
||||
def validate_schema(case: dict, schema: dict, path: Path) -> list[str]:
|
||||
"""Validate a test case against the JSON schema. Returns list of errors."""
|
||||
errors = []
|
||||
try:
|
||||
jsonschema.validate(case, schema)
|
||||
except jsonschema.ValidationError as e:
|
||||
errors.append(f"Schema: {e.message}")
|
||||
return errors
|
||||
|
||||
|
||||
def validate_decode(case: dict, path: Path) -> list[str]:
|
||||
"""Run the decoder and check output against expected fields."""
|
||||
errors = []
|
||||
packet = bytes.fromhex(case["packet_hex"]) if case["packet_hex"] else b""
|
||||
expected = case["expected"]
|
||||
|
||||
# Run decoder — must not crash
|
||||
try:
|
||||
raw_output = decode_meshcore_packet(packet)
|
||||
except Exception as e:
|
||||
errors.append(f"Decoder crashed: {e}")
|
||||
return errors
|
||||
|
||||
output = strip_ansi(raw_output)
|
||||
|
||||
if case["valid"]:
|
||||
# Valid packet: should contain the expected payload type name
|
||||
ptype = expected["payload_type"]
|
||||
if ptype not in output:
|
||||
errors.append(f"Expected '{ptype}' in output, got: {output[:120]}")
|
||||
|
||||
# Check route type
|
||||
rtype = expected["route_type"]
|
||||
if rtype not in output:
|
||||
errors.append(f"Expected route '{rtype}' in output, got: {output[:120]}")
|
||||
|
||||
# Check path hops if specified
|
||||
if "path" in expected:
|
||||
exp_path = expected["path"]
|
||||
for hop in exp_path["hops"]:
|
||||
if hop not in output:
|
||||
errors.append(f"Expected hop '{hop}' in output")
|
||||
|
||||
# Check payload-specific fields
|
||||
payload = expected.get("payload")
|
||||
if payload:
|
||||
_check_payload(payload, output, errors)
|
||||
|
||||
# Should NOT contain error markers
|
||||
for marker in ["<too short>", "<not meshcore>", "<bad framing>"]:
|
||||
if marker in output:
|
||||
errors.append(f"Valid packet produced error marker: {marker}")
|
||||
|
||||
else:
|
||||
# Invalid packet: must not crash. Error marker check is best-effort
|
||||
# (random mutations may accidentally produce parseable packets).
|
||||
pass # decoder didn't crash — that's the key requirement
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _check_payload(payload: dict, output: str, errors: list[str]):
|
||||
"""Check payload-specific fields in the decoder output."""
|
||||
if "ack_crc" in payload:
|
||||
crc_hex = f"0x{payload['ack_crc']:08x}"
|
||||
if crc_hex not in output:
|
||||
errors.append(f"Expected CRC '{crc_hex}' in output")
|
||||
|
||||
if "dst" in payload:
|
||||
if payload["dst"] not in output:
|
||||
errors.append(f"Expected dst='{payload['dst']}' in output")
|
||||
|
||||
if "src" in payload:
|
||||
if payload["src"] not in output:
|
||||
errors.append(f"Expected src='{payload['src']}' in output")
|
||||
|
||||
if "mac" in payload:
|
||||
if payload["mac"] not in output:
|
||||
errors.append(f"Expected mac='{payload['mac']}' in output")
|
||||
|
||||
if "channel_hash" in payload:
|
||||
if payload["channel_hash"] not in output:
|
||||
errors.append(f"Expected ch='{payload['channel_hash']}' in output")
|
||||
|
||||
if "ciphertext_len" in payload:
|
||||
ct_str = f"[{payload['ciphertext_len']}B]"
|
||||
if ct_str not in output:
|
||||
errors.append(f"Expected '{ct_str}' in output")
|
||||
|
||||
if "pubkey_prefix" in payload:
|
||||
pk = payload["pubkey_prefix"]
|
||||
if pk not in output:
|
||||
errors.append(f"Expected pubkey prefix '{pk}' in output")
|
||||
|
||||
if "advert" in payload and payload["advert"] is not None:
|
||||
advert = payload["advert"]
|
||||
if "name" in advert and advert["name"] is not None:
|
||||
if advert["name"] not in output:
|
||||
errors.append(f"Expected name '{advert['name']}' in output")
|
||||
if "node_type" in advert:
|
||||
if advert["node_type"] not in output:
|
||||
errors.append(f"Expected node_type '{advert['node_type']}' in output")
|
||||
if "location" in advert and advert["location"] is not None:
|
||||
loc = advert["location"]
|
||||
lat_str = f"{loc['lat']:.4f}"
|
||||
if lat_str not in output:
|
||||
errors.append(f"Expected lat '{lat_str}' in output")
|
||||
|
||||
if "ephem_prefix" in payload:
|
||||
ep = payload["ephem_prefix"]
|
||||
if ep not in output:
|
||||
errors.append(f"Expected ephemeral prefix '{ep}' in output")
|
||||
|
||||
if "multipart_remaining" in payload:
|
||||
r = payload["multipart_remaining"]
|
||||
if f"remaining={r}" not in output.replace(" ", ""):
|
||||
errors.append(f"Expected remaining={r} in output")
|
||||
|
||||
if "multipart_inner" in payload:
|
||||
inner = payload["multipart_inner"]
|
||||
if inner not in output:
|
||||
errors.append(f"Expected inner type '{inner}' in output")
|
||||
|
||||
if "control_subtype" in payload:
|
||||
sub = payload["control_subtype"]
|
||||
if sub not in output:
|
||||
errors.append(f"Expected control subtype '{sub}' in output")
|
||||
|
||||
|
||||
def main():
|
||||
schema = load_schema()
|
||||
cases = find_test_cases()
|
||||
|
||||
if not cases:
|
||||
print("No test cases found!")
|
||||
sys.exit(1)
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
total_errors: list[tuple[str, list[str]]] = []
|
||||
|
||||
for path in cases:
|
||||
case = json.loads(path.read_text())
|
||||
name = f"{path.parent.name}/{path.stem}"
|
||||
|
||||
errors = validate_schema(case, schema, path)
|
||||
errors.extend(validate_decode(case, path))
|
||||
|
||||
if errors:
|
||||
failed += 1
|
||||
total_errors.append((name, errors))
|
||||
print(f" \033[1;31mFAIL\033[0m {name}")
|
||||
for e in errors:
|
||||
print(f" {e}")
|
||||
else:
|
||||
passed += 1
|
||||
print(f" \033[32mPASS\033[0m {name}")
|
||||
|
||||
print()
|
||||
print(f"\033[1m{passed + failed} tests: {passed} passed, {failed} failed\033[0m")
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user