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:
swaits committed 2026-03-29 20:42:58 -06:00
1 parent 8790ebfd7b
commit 8a5d8c419e
36 files changed
+29 -3

No files matched your search

+607
View File
@@ -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}/")
+327
View File
@@ -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
}
}
}
+128
View File
@@ -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
}
}
}
+207
View File
@@ -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()