Files
MicrOBU/obu-firmware/test/host/check_replay.py
T

254 lines
9.9 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Independent check of test_replay's output. See README.md.
For every recorded frame this works out on its own what gn_unwrap_its should have produced -
GeoNetworking and BTP parsed here from EN 302 636-4-1, the TS 103 097 security envelope decoded by
asn1tools from the IEEE 1609.2 ASN.1 modules rather than by hand - and compares that with what the
C code did, record by record. Then it decodes every distinct extracted message with asn1tools and
re-encodes it. Only a message cut at exactly the right byte re-encodes to the same bytes, so this
is what proves that no trailer or signature bytes came along with it.
py -3.11 check_replay.py replay.tsv capture.pcap [capture.pcap ...]
Needs asn1tools (installed for Python 3.11 on this PC). Message modules come from the repo's
asn1/; the IEEE 1609.2 ones from asn1/ or, failing that, the gitignored vanetza/ checkout.
Exit status 1 if the C code and this disagree about any record.
"""
import collections
import pathlib
import struct
import sys
import asn1tools
ROOT = pathlib.Path(__file__).resolve().parents[3]
PORT_NAMES = {2001: "CAM", 2002: "DENM", 2004: "SPATEM"}
# main.c's RX_FRAME_MAX_LEN: the most of any frame the board hands to gn_unwrap_its. test_replay
# cuts frames to it, so this does too.
RX_FRAME_MAX_LEN = 800
def compile_specs():
a = ROOT / "asn1"
uper = {
2001: asn1tools.compile_files([str(a / "cam_1_4_1.asn"), str(a / "cdd_1_3_1_1.asn")], "uper"),
2002: asn1tools.compile_files([str(a / "denm_1_3_1.asn"), str(a / "cdd_1_3_1_1.asn")], "uper"),
2004: asn1tools.compile_files(
[str(a / n) for n in ("spatem_2_2_1.asn", "mapem_2_2_1.asn", "dsrc_2_2_1.asn", "cdd_2_2_1.asn")],
"uper"),
}
names = ("IEEE1609dot2BaseTypes.asn", "IEEE1609dot2.asn")
for d in (a, ROOT / "vanetza" / "asn1"):
if all((d / n).exists() for n in names):
return uper, asn1tools.compile_files([str(d / n) for n in names], "oer"), d
return uper, None, None
def frames(path):
"""(record index, 802.11 frame) for every record, numbered the way test_util.c numbers them."""
d = pathlib.Path(path).read_bytes()
if len(d) < 24:
return
magic = struct.unpack("<I", d[:4])[0]
e = "<" if magic in (0xA1B2C3D4, 0xA1B23C4D) else ">"
link = struct.unpack(e + "I", d[20:24])[0]
if link not in (105, 127):
return
off, index = 24, 0
while off + 16 <= len(d):
incl = struct.unpack(e + "I", d[off + 8:off + 12])[0]
if incl > len(d) - off - 16:
break
pkt = d[off + 16:off + 16 + incl]
off += 16 + incl
if link == 127:
rl = pkt[2] | pkt[3] << 8 if len(pkt) >= 4 else len(pkt) + 1
if rl > len(pkt):
index += 1
continue
pkt = pkt[rl:]
yield index, pkt
index += 1
def open_envelope(sec, env):
"""(inner GeoNetworking packet, signed) of a TS 103 097 envelope per asn1tools, or None."""
try:
m = sec.decode("Ieee1609Dot2Data", env)
except Exception:
return None
if m["protocolVersion"] != 3:
return None
kind, content = m["content"]
if kind == "unsecuredData":
return content, False
if kind == "signedData":
data = content["tbsData"]["payload"].get("data")
if data and data["protocolVersion"] == 3 and data["content"][0] == "unsecuredData":
return data["content"][1], True
return None
def u16(b):
return int.from_bytes(b, "big")
def s32(b):
return int.from_bytes(b, "big", signed=True)
def expect(f, sec):
"""What gn_unwrap_its should report for frame f: None, or a dict matching test_replay's row.
Second value: how many bytes after the message the pre-2026-09-11 code would have forwarded."""
if len(f) < 24 or (f[0] >> 2) & 3 != 2 or f[1] & 3 == 3:
return None, None
o = 24 + (2 if f[0] & 0x80 else 0)
if f[o:o + 8] != b"\xaa\xaa\x03\x00\x00\x00\x89\x47" or len(f) < o + 12:
return None, None
nh = f[o + 8] & 0x0F
o += 12
if nh == 2:
if sec is None:
return "unchecked", None
opened = open_envelope(sec, f[o:])
if opened is None:
return None, None
region, signed = opened
elif nh == 1:
region, signed = f[o:], False
else:
return None, None
if len(region) < 8 or region[0] >> 4 != 2:
return None, None
ht, hst, pl = region[1] >> 4, region[1] & 0x0F, u16(region[4:6])
if ht == 5 and hst == 0:
ext, area = 28, None
elif ht == 4:
ext = 44
else:
return None, None
if len(region) < 8 + ext + 4:
return None, None
if ht == 4:
a = 8 + 28
area = (s32(region[a:a + 4]), s32(region[a + 4:a + 8]), u16(region[a + 8:a + 10]))
port = u16(region[8 + ext:8 + ext + 2])
if port not in PORT_NAMES or pl <= 4:
return None, None
start, end = 8 + ext + 4, 8 + ext + pl
if signed is not None and nh == 2 and end > len(region):
return None, None # the inner packet claims more than its envelope holds
payload = region[start:min(end, len(region))]
if not payload:
return None, None
old_extra = len(region) - end if nh == 1 else None
return dict(port=port, signed=signed, truncated=end > len(region), area=area, payload=payload), old_extra
def read_tsv(path):
rows = {}
with open(path, encoding="ascii") as fh:
next(fh)
for line in fh:
c = line.rstrip("\n").split("\t")
key = (c[0], int(c[1]))
if c[2] == "0":
rows[key] = None
else:
rows[key] = dict(port=int(c[3]), signed=c[4] == "1", truncated=c[5] == "1",
area=(int(c[7]), int(c[8]), int(c[9])) if c[6] == "1" else None,
payload=bytes.fromhex(c[10]))
return rows
def describe(r):
if r is None:
return "rejected"
return "port %d signed %s truncated %s area %s %d bytes" % (
r["port"], r["signed"], r["truncated"], r["area"], len(r["payload"]))
def main(argv):
if len(argv) < 2:
sys.exit(__doc__)
got = read_tsv(argv[0])
uper, sec, sec_dir = compile_specs()
if sec is None:
print("WARNING: IEEE 1609.2 modules not found; secured frames are not checked independently")
stats, extra, disagreements, messages = collections.Counter(), collections.Counter(), [], {}
for path in argv[1:]:
for index, f in frames(path):
key = (path, index)
stats["records"] += 1
stats["capped"] += len(f) > RX_FRAME_MAX_LEN
want, old_extra = expect(f[:RX_FRAME_MAX_LEN], sec)
if key not in got:
disagreements.append((key, "no row from test_replay"))
continue
have = got.pop(key)
if want == "unchecked":
stats["secured, unchecked"] += 1
continue
if want != have:
disagreements.append((key, "C: %s | independent: %s" % (describe(have), describe(want))))
continue
if want:
stats["accepted"] += 1
stats[PORT_NAMES[want["port"]]] += 1
stats["signed"] += want["signed"]
stats["truncated"] += want["truncated"]
if old_extra is not None:
extra[old_extra] += 1
messages.setdefault((want["port"], want["payload"]), key)
for key in got:
disagreements.append((key, "row from test_replay for a record this did not see"))
print("check_replay: %d records (%d cut to %d bytes), %d accepted (CAM %d, DENM %d, SPATEM %d; "
"%d signed, %d truncated)"
% (stats["records"], stats["capped"], RX_FRAME_MAX_LEN, stats["accepted"], stats["CAM"],
stats["DENM"], stats["SPATEM"], stats["signed"], stats["truncated"]))
if sec_dir:
print(" envelopes decoded with asn1tools using %s" % sec_dir.relative_to(ROOT))
print(" C vs independent parse: %s" % ("agree on every record" if not disagreements
else "%d DISAGREEMENTS" % len(disagreements)))
for key, why in disagreements[:15]:
print(" %s record %d: %s" % (pathlib.Path(key[0]).name, key[1], why))
outcome, failures = collections.Counter(), []
for (port, payload), key in messages.items():
name, spec = PORT_NAMES[port], uper[port]
try:
decoded = spec.decode(name, payload)
except Exception as e:
outcome[(name, "does not decode")] += 1
failures.append((key, name, len(payload), str(e)[:110]))
continue
try:
encoded = spec.encode(name, decoded)
except Exception as e:
# asn1tools decodes an alternative from a later module version (a CHOICE extension)
# as (None, None) and then cannot encode it back. Says nothing about where the message
# was cut, so it is reported apart from real mismatches.
outcome[(name, "decodes; uses a newer extension")] += 1
failures.append((key, name, len(payload), "cannot re-encode: " + str(e)[:90]))
continue
if encoded == payload:
outcome[(name, "re-encodes byte-identically")] += 1
else:
outcome[(name, "re-encodes differently")] += 1
failures.append((key, name, len(payload), "re-encoded to different bytes"))
print(" asn1tools on the %d distinct extracted messages:" % len(messages))
for (name, what), n in sorted(outcome.items()):
print(" %-6s %-28s %d" % (name, what, n))
for key, name, n, why in failures[:15]:
print(" %s record %d, %s %d bytes: %s" % (pathlib.Path(key[0]).name, key[1], name, n, why))
print(" bytes the pre-2026-09-11 code forwarded after each unsecured message: %s"
% dict(sorted(extra.items())))
return 1 if disagreements else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))