diff --git a/scapy/contrib/zenoh.py b/scapy/contrib/zenoh.py new file mode 100644 index 00000000000..e276cb29c89 --- /dev/null +++ b/scapy/contrib/zenoh.py @@ -0,0 +1,700 @@ +# SPDX-License-Identifier: GPL-2.0-only +# This file is part of Scapy +# See https://scapy.net/ for more information + +# scapy.contrib.description = Zenoh Protocol +# scapy.contrib.status = loads + +""" +Zenoh protocol for Scapy. + +Implements the zenoh 1.0 wire format for publish/subscribe/query +communication in IoT and edge computing environments. + +Default ports: +- UDP 7446: scouting (multicast peer discovery) +- UDP/TCP 7447: data transport + +References: +- https://zenoh.io/ +- https://github.com/eclipse-zenoh/zenoh +""" + +from scapy.compat import chb, orb +from scapy.config import conf +from scapy.fields import ( + BitEnumField, + BitField, + ByteField, + ConditionalField, + Field, + LELongField, + LEShortField, +) +from scapy.layers.inet import TCP, UDP +from scapy.packet import Packet, bind_layers +from scapy.volatile import RandNum + + +# ============================================================================ +# Custom Fields +# ============================================================================ + + +class ZenohVarIntField(Field): + """Variable-length integer field (zenoh varint encoding). + + Uses little-endian 7-bit groups: each byte contributes 7 bits, + bit 7 (MSB) of each byte indicates more bytes follow. + + For example, 300 decimal (0x12C) encodes as two bytes: + + - byte 0: (0x12C & 0x7F) | 0x80 = 0xAC (more bytes follow) + - byte 1: 0x12C >> 7 = 0x02 (last byte) + + Wire representation: ``[0xAC, 0x02]`` + """ + + def __init__(self, name, default): + Field.__init__(self, name, default, "B") + + def addfield(self, pkt, s, val): + if val is None: + val = 0 + data = bytearray() + while val > 0x7F: + data.append((val & 0x7F) | 0x80) + val >>= 7 + data.append(val & 0x7F) + return s + bytes(data) + + def getfield(self, pkt, s): + value = 0 + shift = 0 + for i in range(len(s)): + b = orb(s[i]) + value |= (b & 0x7F) << shift + shift += 7 + if not (b & 0x80): + return s[i + 1:], value + return b"", value + + def i2repr(self, pkt, val): + return repr(val) + + def randval(self): + return RandNum(0, 0xFFFF) + + +class ZenohIDField(Field): + """Zenoh node ID field. + + Wire format: 1-byte length prefix followed by the ID bytes. + The Zenoh 1.0 spec defines ZIDs as 1-16 bytes; this field accepts + any 0-255 length to allow dissection of malformed packets. + """ + + def __init__(self, name, default): + Field.__init__(self, name, default, "B") + + def addfield(self, pkt, s, val): + if val is None: + val = b"" + if isinstance(val, str): + val = val.encode() + return s + chb(len(val)) + val + + def getfield(self, pkt, s): + if not s: + return b"", b"" + length = orb(s[0]) + return s[1 + length:], s[1:1 + length] + + def i2repr(self, pkt, val): + if isinstance(val, bytes): + return val.hex() + return "" + + def i2h(self, pkt, val): + return val if val is not None else b"" + + def h2i(self, pkt, val): + if isinstance(val, str): + try: + return bytes.fromhex(val) + except ValueError: + return val.encode() + return val if val is not None else b"" + + +class ZenohBytesField(Field): + """Variable-length bytes field with VarInt length prefix. + + Used for cookie and payload fields in zenoh messages. + """ + + def __init__(self, name, default): + Field.__init__(self, name, default, "B") + + def _encode_varint(self, val): + data = bytearray() + while val > 0x7F: + data.append((val & 0x7F) | 0x80) + val >>= 7 + data.append(val & 0x7F) + return bytes(data) + + def _decode_varint(self, s): + value = 0 + shift = 0 + for i in range(len(s)): + b = orb(s[i]) + value |= (b & 0x7F) << shift + shift += 7 + if not (b & 0x80): + return value, i + 1 + return 0, len(s) + + def addfield(self, pkt, s, val): + if val is None: + val = b"" + if isinstance(val, str): + val = val.encode() + return s + self._encode_varint(len(val)) + val + + def getfield(self, pkt, s): + if not s: + return b"", b"" + length, consumed = self._decode_varint(s) + return s[consumed + length:], s[consumed:consumed + length] + + def i2repr(self, pkt, val): + if isinstance(val, bytes): + return val.hex() + return "" + + +# ============================================================================ +# Constants +# ============================================================================ + +# Scouting message IDs (bits [4:0] of the header byte) +ZENOH_SCOUTING_MID = { + 0x01: "Scout", + 0x02: "Hello", +} + +# Transport message IDs (bits [4:0] of the header byte) +ZENOH_TRANSPORT_MID = { + 0x00: "Init", + 0x01: "Open", + 0x04: "KeepAlive", + 0x05: "Close", + 0x06: "Frame", + 0x07: "Fragment", + 0x08: "Join", +} + +# Network message IDs (bits [4:0] of header byte within a Frame payload) +ZENOH_NETWORK_MID = { + 0x00: "Push", + 0x01: "Request", + 0x02: "Response", + 0x03: "ResponseFinal", + 0x05: "Declare", + 0x1f: "OAM", +} + +# WhatAmI bitmask values +ZENOH_WHATAMI = { + 0x01: "Router", + 0x02: "Peer", + 0x04: "Client", +} + +# Close reason codes +ZENOH_CLOSE_REASON = { + 0x00: "Generic", + 0x01: "Unsupported", + 0x02: "Invalid", + 0x03: "MaxLinks", + 0x04: "Expired", +} + + +# ============================================================================ +# Scouting Messages (typically on UDP port 7446) +# ============================================================================ + +class ZenohScout(Packet): + """Zenoh Scout message - sent to discover peers on the network. + + Header byte layout: [_|_|Z][SCOUT(0x01)] + bit 7: _ (reserved) + bit 6: _ (reserved) + bit 5: Z - zenoh extensions present + bits[4:0]: 0x01 (Scout MID) + """ + name = "ZenohScout" + fields_desc = [ + BitField("flag_reserved1", 0, 1), + BitField("flag_reserved2", 0, 1), + BitField("flag_z", 0, 1), + BitEnumField("mid", 0x01, 5, ZENOH_SCOUTING_MID), + ByteField("version", 0x01), + ZenohVarIntField("what", 0x07), + ] + + def guess_payload_class(self, payload): + return conf.padding_layer + + +class ZenohHello(Packet): + """Zenoh Hello message - unicast response to Scout. + + Header byte layout: [L|_|Z][HELLO(0x02)] + bit 7: L - locators list is present + bit 6: _ (reserved) + bit 5: Z - zenoh extensions present + bits[4:0]: 0x02 (Hello MID) + """ + name = "ZenohHello" + fields_desc = [ + BitEnumField("flag_l", 0, 1, {0: "NoLocators", 1: "Locators"}), + BitField("flag_reserved", 0, 1), + BitField("flag_z", 0, 1), + BitEnumField("mid", 0x02, 5, ZENOH_SCOUTING_MID), + ByteField("version", 0x01), + ZenohVarIntField("what", 0x02), + ZenohIDField("zid", b""), + ] + + def guess_payload_class(self, payload): + return conf.padding_layer + + +# ============================================================================ +# Transport Messages (TCP/UDP port 7447) +# ============================================================================ + +class ZenohInit(Packet): + """Zenoh Init message - bidirectional session initialization. + + When flag_a == 0: InitSyn (client → router/peer) + When flag_a == 1: InitAck (router/peer → client) + + Header byte layout: [A|S|Z][INIT(0x00)] + bit 7: A - Ack (0=Syn, 1=Ack) + bit 6: S - SN/batch-size resolution present + bit 5: Z - zenoh extensions present + bits[4:0]: 0x00 (Init MID) + """ + name = "ZenohInit" + fields_desc = [ + BitEnumField("flag_a", 0, 1, {0: "Syn", 1: "Ack"}), + BitField("flag_s", 0, 1), + BitField("flag_z", 0, 1), + BitEnumField("mid", 0x00, 5, ZENOH_TRANSPORT_MID), + ByteField("version", 0x01), + ZenohVarIntField("what", 0x02), + ZenohIDField("zid", b""), + # Resolution and batch size are present when flag_s == 1 + ConditionalField(ZenohVarIntField("resolution", 0x0200), + lambda pkt: pkt.flag_s == 1), + ConditionalField(LEShortField("batch_size", 65535), + lambda pkt: pkt.flag_s == 1), + # Nonce and cookie are only in the Ack (flag_a == 1) + ConditionalField(LELongField("nonce", 0), + lambda pkt: pkt.flag_a == 1), + ConditionalField(ZenohBytesField("cookie", b""), + lambda pkt: pkt.flag_a == 1), + ] + + def guess_payload_class(self, payload): + return conf.padding_layer + + +class ZenohOpen(Packet): + """Zenoh Open message - opens a confirmed transport session. + + When flag_a == 0: OpenSyn (initiator) + When flag_a == 1: OpenAck (responder) + + Header byte layout: [A|_|Z][OPEN(0x01)] + bit 7: A - Ack (0=Syn, 1=Ack) + bit 6: _ (reserved) + bit 5: Z - zenoh extensions present + bits[4:0]: 0x01 (Open MID) + """ + name = "ZenohOpen" + fields_desc = [ + BitEnumField("flag_a", 0, 1, {0: "Syn", 1: "Ack"}), + BitField("flag_reserved", 0, 1), + BitField("flag_z", 0, 1), + BitEnumField("mid", 0x01, 5, ZENOH_TRANSPORT_MID), + # Lease is present only in the Syn (flag_a == 0) + ConditionalField(ZenohVarIntField("lease", 10000), + lambda pkt: pkt.flag_a == 0), + ZenohVarIntField("initial_sn", 0), + # Cookie is present only in the Syn (flag_a == 0) + ConditionalField(ZenohBytesField("cookie", b""), + lambda pkt: pkt.flag_a == 0), + ] + + def guess_payload_class(self, payload): + return conf.padding_layer + + +class ZenohClose(Packet): + """Zenoh Close message - terminates a session or link. + + Header byte layout: [L|_|_][CLOSE(0x05)] + bit 7: L - link-only close (0=full session, 1=link only) + bit 6: _ (reserved) + bit 5: _ (reserved) + bits[4:0]: 0x05 (Close MID) + """ + name = "ZenohClose" + fields_desc = [ + BitEnumField("flag_l", 0, 1, {0: "Session", 1: "Link"}), + BitField("flag_reserved1", 0, 1), + BitField("flag_reserved2", 0, 1), + BitEnumField("mid", 0x05, 5, ZENOH_TRANSPORT_MID), + # Reason is only present for session close (flag_l == 0) + ConditionalField(ByteField("reason", 0), + lambda pkt: pkt.flag_l == 0), + ] + + def guess_payload_class(self, payload): + return conf.padding_layer + + +class ZenohKeepAlive(Packet): + """Zenoh KeepAlive message - maintains an active session. + + Header byte layout: [A|_|_][KEEPALIVE(0x04)] + bit 7: A - Reply (0=request, 1=reply) + bit 6: _ (reserved) + bit 5: _ (reserved) + bits[4:0]: 0x04 (KeepAlive MID) + """ + name = "ZenohKeepAlive" + fields_desc = [ + BitEnumField("flag_a", 0, 1, {0: "Request", 1: "Reply"}), + BitField("flag_reserved1", 0, 1), + BitField("flag_reserved2", 0, 1), + BitEnumField("mid", 0x04, 5, ZENOH_TRANSPORT_MID), + ] + + def guess_payload_class(self, payload): + return conf.padding_layer + + +class ZenohNetworkMsg(Packet): + """Zenoh network message dispatched within a Frame. + + Network messages begin with a 1-byte header containing the message ID + in bits [4:0]. This class dispatches to the specific network message + type based on that ID. + """ + name = "ZenohNetworkMsg" + fields_desc = [] + + def do_dissect(self, s): + return s + + def guess_payload_class(self, payload): + if not payload: + return conf.padding_layer + mid = orb(payload[0]) & 0x1F + return _NETWORK_MSG_CLASSES.get(mid, conf.raw_layer) + + +class ZenohFrame(Packet): + """Zenoh Frame message - transport container for network messages. + + The payload may contain one or more zenoh network messages on the wire. + This dissector dispatches a single network message based on the first + payload byte; any remaining bytes are left to Raw/Padding layers. + + Header byte layout: [_|_|R][FRAME(0x06)] + bit 7: _ (reserved) + bit 6: _ (reserved) + bit 5: R - Reliable channel (0=BestEffort, 1=Reliable) + bits[4:0]: 0x06 (Frame MID) + """ + name = "ZenohFrame" + fields_desc = [ + BitField("flag_reserved1", 0, 1), + BitField("flag_reserved2", 0, 1), + BitEnumField("flag_r", 0, 1, {0: "BestEffort", 1: "Reliable"}), + BitEnumField("mid", 0x06, 5, ZENOH_TRANSPORT_MID), + ZenohVarIntField("sn", 0), + ] + + def guess_payload_class(self, payload): + if not payload: + return conf.padding_layer + mid = orb(payload[0]) & 0x1F + return _NETWORK_MSG_CLASSES.get(mid, conf.raw_layer) + + +class ZenohFragment(Packet): + """Zenoh Fragment message - carries a fragment of a large network message. + + Header byte layout: [M|_|R][FRAGMENT(0x07)] + bit 7: M - More fragments follow + bit 6: _ (reserved) + bit 5: R - Reliable channel (0=BestEffort, 1=Reliable) + bits[4:0]: 0x07 (Fragment MID) + """ + name = "ZenohFragment" + fields_desc = [ + BitEnumField("flag_m", 0, 1, {0: "Last", 1: "More"}), + BitField("flag_reserved", 0, 1), + BitEnumField("flag_r", 0, 1, {0: "BestEffort", 1: "Reliable"}), + BitEnumField("mid", 0x07, 5, ZENOH_TRANSPORT_MID), + ZenohVarIntField("sn", 0), + ] + + +class ZenohJoin(Packet): + """Zenoh Join message - announces presence on a multicast transport. + + Header byte layout: [_|T|Z][JOIN(0x08)] + bit 7: _ (reserved) + bit 6: T - Lease time present + bit 5: Z - zenoh extensions present + bits[4:0]: 0x08 (Join MID) + """ + name = "ZenohJoin" + fields_desc = [ + BitField("flag_reserved", 0, 1), + BitEnumField("flag_t", 0, 1, {0: "NoLease", 1: "Lease"}), + BitField("flag_z", 0, 1), + BitEnumField("mid", 0x08, 5, ZENOH_TRANSPORT_MID), + ByteField("version", 0x01), + ZenohVarIntField("what", 0x02), + ZenohIDField("zid", b""), + ZenohVarIntField("resolution", 0x0200), + LEShortField("batch_size", 65535), + ConditionalField(ZenohVarIntField("lease", 10000), + lambda pkt: pkt.flag_t == 1), + # Sequence numbers: reliable SN and best-effort SN + ZenohVarIntField("next_sn_reliable", 0), + ZenohVarIntField("next_sn_best_effort", 0), + ] + + def guess_payload_class(self, payload): + return conf.padding_layer + + +# ============================================================================ +# Network Messages (within ZenohFrame payload) +# ============================================================================ + +class ZenohPush(Packet): + """Zenoh Push (data publication) network message. + + Header byte layout: [N|Z|_][PUSH(0x00)] + bit 7: N - No subscribers (hint) + bit 6: Z - zenoh extensions present + bit 5: _ (reserved) + bits[4:0]: 0x00 (Push MID) + """ + name = "ZenohPush" + fields_desc = [ + BitEnumField("flag_n", 0, 1, {0: "Subscribers", 1: "NoSubscribers"}), + BitField("flag_z", 0, 1), + BitField("flag_reserved", 0, 1), + BitEnumField("mid", 0x00, 5, ZENOH_NETWORK_MID), + ZenohVarIntField("wire_expr_id", 0), + ] + + +class ZenohRequest(Packet): + """Zenoh Request (query) network message. + + Header byte layout: [_|Z|_][REQUEST(0x01)] + bit 7: _ (reserved) + bit 6: Z - zenoh extensions present + bit 5: _ (reserved) + bits[4:0]: 0x01 (Request MID) + """ + name = "ZenohRequest" + fields_desc = [ + BitField("flag_reserved1", 0, 1), + BitField("flag_z", 0, 1), + BitField("flag_reserved2", 0, 1), + BitEnumField("mid", 0x01, 5, ZENOH_NETWORK_MID), + ZenohVarIntField("rid", 0), + ZenohVarIntField("wire_expr_id", 0), + ] + + +class ZenohResponse(Packet): + """Zenoh Response network message - carries a query reply. + + Header byte layout: [_|Z|_][RESPONSE(0x02)] + bit 7: _ (reserved) + bit 6: Z - zenoh extensions present + bit 5: _ (reserved) + bits[4:0]: 0x02 (Response MID) + """ + name = "ZenohResponse" + fields_desc = [ + BitField("flag_reserved1", 0, 1), + BitField("flag_z", 0, 1), + BitField("flag_reserved2", 0, 1), + BitEnumField("mid", 0x02, 5, ZENOH_NETWORK_MID), + ZenohVarIntField("rid", 0), + ZenohVarIntField("entity_id", 0), + ] + + +class ZenohResponseFinal(Packet): + """Zenoh ResponseFinal network message - signals end of query responses. + + Header byte layout: [_|Z|_][RESPONSE_FINAL(0x03)] + bit 7: _ (reserved) + bit 6: Z - zenoh extensions present + bit 5: _ (reserved) + bits[4:0]: 0x03 (ResponseFinal MID) + """ + name = "ZenohResponseFinal" + fields_desc = [ + BitField("flag_reserved1", 0, 1), + BitField("flag_z", 0, 1), + BitField("flag_reserved2", 0, 1), + BitEnumField("mid", 0x03, 5, ZENOH_NETWORK_MID), + ZenohVarIntField("rid", 0), + ZenohVarIntField("entity_id", 0), + ] + + +class ZenohDeclare(Packet): + """Zenoh Declare network message - declares resources, subscribers, etc. + + Header byte layout: [_|Z|_][DECLARE(0x05)] + bit 7: _ (reserved) + bit 6: Z - zenoh extensions present + bit 5: _ (reserved) + bits[4:0]: 0x05 (Declare MID) + """ + name = "ZenohDeclare" + fields_desc = [ + BitField("flag_reserved1", 0, 1), + BitField("flag_z", 0, 1), + BitField("flag_reserved2", 0, 1), + BitEnumField("mid", 0x05, 5, ZENOH_NETWORK_MID), + ] + + +class ZenohOAM(Packet): + """Zenoh OAM (Operations, Administration, and Maintenance) network message. + + Header byte layout: [_|Z|_][OAM(0x1f)] + bit 7: _ (reserved) + bit 6: Z - zenoh extensions present + bit 5: _ (reserved) + bits[4:0]: 0x1f (OAM MID) + """ + name = "ZenohOAM" + fields_desc = [ + BitField("flag_reserved1", 0, 1), + BitField("flag_z", 0, 1), + BitField("flag_reserved2", 0, 1), + BitEnumField("mid", 0x1f, 5, ZENOH_NETWORK_MID), + ZenohVarIntField("oam_id", 0), + ] + + +# ============================================================================ +# Dispatch Tables +# ============================================================================ + +# Maps transport MID → message class (message includes its own header byte) +_TRANSPORT_MSG_CLASSES = { + 0x00: ZenohInit, + 0x01: ZenohOpen, + 0x04: ZenohKeepAlive, + 0x05: ZenohClose, + 0x06: ZenohFrame, + 0x07: ZenohFragment, + 0x08: ZenohJoin, +} + +# Maps scouting MID → message class +_SCOUTING_MSG_CLASSES = { + 0x01: ZenohScout, + 0x02: ZenohHello, +} + +# Maps network MID → message class (for messages within a Frame) +_NETWORK_MSG_CLASSES = { + 0x00: ZenohPush, + 0x01: ZenohRequest, + 0x02: ZenohResponse, + 0x03: ZenohResponseFinal, + 0x05: ZenohDeclare, + 0x1f: ZenohOAM, +} + + +# ============================================================================ +# Top-level Dispatch Layers +# ============================================================================ + +class ZenohScouting(Packet): + """Dispatcher for zenoh scouting messages (UDP port 7446). + + Reads the first byte of the payload and dispatches to the appropriate + scouting message class based on the 5-bit message ID (bits [4:0]). + """ + name = "ZenohScouting" + fields_desc = [] + + def do_dissect(self, s): + return s + + def guess_payload_class(self, payload): + if not payload: + return conf.padding_layer + mid = orb(payload[0]) & 0x1F + return _SCOUTING_MSG_CLASSES.get(mid, conf.raw_layer) + + +class ZenohTransport(Packet): + """Dispatcher for zenoh transport messages (TCP/UDP port 7447). + + Reads the first byte of the payload and dispatches to the appropriate + transport message class based on the 5-bit message ID (bits [4:0]). + """ + name = "ZenohTransport" + fields_desc = [] + + def do_dissect(self, s): + return s + + def guess_payload_class(self, payload): + if not payload: + return conf.padding_layer + mid = orb(payload[0]) & 0x1F + return _TRANSPORT_MSG_CLASSES.get(mid, conf.raw_layer) + + +# ============================================================================ +# Layer Bindings +# ============================================================================ + +# Scouting messages on UDP port 7446 +bind_layers(UDP, ZenohScouting, dport=7446) +bind_layers(UDP, ZenohScouting, sport=7446) + +# Transport messages on UDP port 7447 +bind_layers(UDP, ZenohTransport, dport=7447) +bind_layers(UDP, ZenohTransport, sport=7447) + +# Transport messages on TCP port 7447 +bind_layers(TCP, ZenohTransport, dport=7447) +bind_layers(TCP, ZenohTransport, sport=7447) diff --git a/test/contrib/zenoh.uts b/test/contrib/zenoh.uts new file mode 100644 index 00000000000..8e143ecb45c --- /dev/null +++ b/test/contrib/zenoh.uts @@ -0,0 +1,757 @@ +% Zenoh Protocol tests + +# Type the following command to launch the tests: +# $ test/run_tests -P "load_contrib('zenoh')" -t test/contrib/zenoh.uts + ++ Syntax check += Import the Zenoh layer +from scapy.contrib.zenoh import * + + ++ ZenohVarIntField tests + += VarInt: encode 0 (single byte) +f = ZenohVarIntField('test', 0) +encoded = f.addfield(None, b'', 0) +assert encoded == bytes([0]) +assert len(encoded) == 1 + += VarInt: encode 127 (max single byte) +f = ZenohVarIntField('test', 0) +encoded = f.addfield(None, b'', 127) +assert encoded == bytes([0x7f]) +assert len(encoded) == 1 + += VarInt: encode 128 (first two-byte value) +f = ZenohVarIntField('test', 0) +encoded = f.addfield(None, b'', 128) +assert encoded == bytes([0x80, 0x01]) +assert len(encoded) == 2 + += VarInt: encode 300 +f = ZenohVarIntField('test', 0) +encoded = f.addfield(None, b'', 300) +assert encoded == bytes([0xac, 0x02]) +assert len(encoded) == 2 + += VarInt: encode 16383 (max two-byte value) +f = ZenohVarIntField('test', 0) +encoded = f.addfield(None, b'', 16383) +assert encoded == bytes([0xff, 0x7f]) +assert len(encoded) == 2 + += VarInt: decode 0 +f = ZenohVarIntField('test', 0) +remainder, decoded = f.getfield(None, bytes([0])) +assert decoded == 0 +assert remainder == b'' + += VarInt: decode 127 +f = ZenohVarIntField('test', 0) +remainder, decoded = f.getfield(None, bytes([0x7f])) +assert decoded == 127 +assert remainder == b'' + += VarInt: decode 128 +f = ZenohVarIntField('test', 0) +remainder, decoded = f.getfield(None, bytes([0x80, 0x01])) +assert decoded == 128 +assert remainder == b'' + += VarInt: decode 300 +f = ZenohVarIntField('test', 0) +remainder, decoded = f.getfield(None, bytes([0xac, 0x02])) +assert decoded == 300 +assert remainder == b'' + += VarInt: trailing bytes remain after decoding +f = ZenohVarIntField('test', 0) +data = bytes([0x2a, 0xff, 0xff]) +remainder, decoded = f.getfield(None, data) +assert decoded == 42 +assert remainder == bytes([0xff, 0xff]) + += VarInt: roundtrip encode/decode +f = ZenohVarIntField('test', 0) +for val in [0, 1, 42, 127, 128, 255, 300, 16383, 65535]: + enc = f.addfield(None, b'', val) + _, dec = f.getfield(None, enc) + assert dec == val + + ++ ZenohBytesField tests + += ZenohBytesField: unterminated length varint consumes input +f = ZenohBytesField('cookie', b'') +data = bytes([0x80, 0x80, 0x80]) +remainder, decoded = f.getfield(None, data) +assert decoded == b'' +assert remainder == b'' + += ZenohBytesField: trailing bytes after unterminated varint +f = ZenohBytesField('cookie', b'') +data = bytes([0x80, 0x80]) +remainder, decoded = f.getfield(None, data) +assert decoded == b'' +assert remainder == b'' + + ++ ZenohIDField tests + += ZenohIDField: encode empty ID +f = ZenohIDField('zid', b'') +encoded = f.addfield(None, b'', b'') +assert encoded == bytes([0]) +assert len(encoded) == 1 + += ZenohIDField: decode empty ID +f = ZenohIDField('zid', b'') +remainder, decoded = f.getfield(None, bytes([0])) +assert decoded == b'' +assert remainder == b'' + += ZenohIDField: encode 4-byte ID +f = ZenohIDField('zid', b'') +encoded = f.addfield(None, b'', bytes([1, 2, 3, 4])) +assert encoded == bytes([4, 1, 2, 3, 4]) +assert len(encoded) == 5 + += ZenohIDField: decode 4-byte ID +f = ZenohIDField('zid', b'') +remainder, decoded = f.getfield(None, bytes([4, 1, 2, 3, 4])) +assert decoded == bytes([1, 2, 3, 4]) +assert remainder == b'' + += ZenohIDField: roundtrip +f = ZenohIDField('zid', b'') +zid = bytes(range(1, 9)) +enc = f.addfield(None, b'', zid) +_, dec = f.getfield(None, enc) +assert dec == zid + + ++ ZenohScout tests + += ZenohScout: build with default values +scout = ZenohScout() +data = raw(scout) +assert data == bytes([0x01, 0x01, 0x07]) + += ZenohScout: field values +scout = ZenohScout() +assert scout.mid == 0x01 +assert scout.version == 0x01 +assert scout.what == 0x07 +assert scout.flag_z == 0 + += ZenohScout: build with Z flag set +scout = ZenohScout(flag_z=1) +data = raw(scout) +assert data == bytes([0x21, 0x01, 0x07]) + += ZenohScout: dissect +data = bytes([0x01, 0x01, 0x07]) +scout = ZenohScout(data) +assert scout.mid == 1 +assert scout.version == 1 +assert scout.what == 7 + += ZenohScout: dissect peer-only what +data = bytes([0x01, 0x01, 0x02]) +scout = ZenohScout(data) +assert scout.what == 2 + + ++ ZenohHello tests + += ZenohHello: build +hello = ZenohHello(what=2, zid=bytes([1, 2, 3, 4])) +data = raw(hello) +assert data == bytes([0x02, 0x01, 0x02, 0x04, 0x01, 0x02, 0x03, 0x04]) + += ZenohHello: field values +hello = ZenohHello(what=2, zid=bytes([1, 2, 3, 4])) +assert hello.mid == 2 +assert hello.version == 1 +assert hello.what == 2 +assert hello.flag_l == 0 + += ZenohHello: build with locators flag +hello = ZenohHello(flag_l=1, what=1, zid=bytes([0xab, 0xcd])) +data = raw(hello) +assert data[0] == 0x82 + += ZenohHello: dissect +data = bytes([0x02, 0x01, 0x02, 0x04, 0x01, 0x02, 0x03, 0x04]) +hello = ZenohHello(data) +assert hello.mid == 2 +assert hello.version == 1 +assert hello.what == 2 +assert hello.zid == bytes([1, 2, 3, 4]) +assert hello.flag_l == 0 + += ZenohHello: dissect with locators flag +data = bytes([0x82, 0x01, 0x01, 0x02, 0xab, 0xcd]) +hello = ZenohHello(data) +assert hello.flag_l == 1 +assert hello.zid == bytes([0xab, 0xcd]) + + ++ ZenohInit tests + += ZenohInit: build InitSyn (no resolution) +init = ZenohInit(flag_a=0, flag_s=0, what=2, zid=bytes([0xab])) +data = raw(init) +assert data == bytes([0x00, 0x01, 0x02, 0x01, 0xab]) + += ZenohInit: build InitSyn with resolution (flag_s=1) +init = ZenohInit(flag_a=0, flag_s=1, what=2, zid=bytes([0xab]), resolution=512, batch_size=65535) +data = raw(init) +assert data[0] == 0x40 +parsed = ZenohInit(data) +assert parsed.flag_s == 1 +assert parsed.resolution == 512 +assert parsed.batch_size == 65535 + += ZenohInit: InitAck header byte +init = ZenohInit(flag_a=1, what=1, zid=bytes([0xcd]), nonce=0xDEADBEEF, cookie=b'cookie') +data = raw(init) +assert data[0] == 0x80 + += ZenohInit: InitAck fields +init = ZenohInit(flag_a=1, what=1, zid=bytes([0xcd]), nonce=0xDEADBEEF, cookie=b'cookie') +data = raw(init) +parsed = ZenohInit(data) +assert parsed.flag_a == 1 +assert parsed.nonce == 0xDEADBEEF +assert parsed.cookie == b'cookie' + += ZenohInit: dissect InitSyn +data = bytes([0x00, 0x01, 0x02, 0x01, 0xab]) +init = ZenohInit(data) +assert init.flag_a == 0 +assert init.flag_s == 0 +assert init.version == 1 +assert init.what == 2 +assert init.zid == bytes([0xab]) + += ZenohInit: conditional fields absent when flag_s=0 and flag_a=0 +init = ZenohInit(flag_a=0, flag_s=0, what=2, zid=bytes([0xab])) +assert init.resolution is None +assert init.batch_size is None +assert init.nonce is None +assert init.cookie is None + + ++ ZenohOpen tests + += ZenohOpen: build OpenSyn header byte +open_pkt = ZenohOpen(flag_a=0, lease=10000, initial_sn=0, cookie=b'ck') +data = raw(open_pkt) +assert data[0] == 0x01 + += ZenohOpen: OpenSyn fields +open_pkt = ZenohOpen(flag_a=0, lease=10000, initial_sn=0, cookie=b'ck') +data = raw(open_pkt) +parsed = ZenohOpen(data) +assert parsed.flag_a == 0 +assert parsed.lease == 10000 +assert parsed.initial_sn == 0 +assert parsed.cookie == b'ck' + += ZenohOpen: build OpenAck +open_pkt = ZenohOpen(flag_a=1, initial_sn=0) +data = raw(open_pkt) +assert data == bytes([0x81, 0x00]) + += ZenohOpen: dissect OpenAck +data = bytes([0x81, 0x00]) +parsed = ZenohOpen(data) +assert parsed.flag_a == 1 +assert parsed.initial_sn == 0 +assert parsed.lease is None +assert parsed.cookie is None + += ZenohOpen: dissect OpenSyn +data = bytes([0x01, 0x90, 0x4e, 0x00, 0x02, ord('c'), ord('k')]) +parsed = ZenohOpen(data) +assert parsed.flag_a == 0 +assert parsed.lease == 10000 +assert parsed.initial_sn == 0 +assert parsed.cookie == b'ck' + + ++ ZenohClose tests + += ZenohClose: build session close +close = ZenohClose(flag_l=0, reason=0) +data = raw(close) +assert data == bytes([0x05, 0x00]) + += ZenohClose: build link close +close = ZenohClose(flag_l=1) +data = raw(close) +assert data == bytes([0x85]) + += ZenohClose: dissect session close +data = bytes([0x05, 0x00]) +close = ZenohClose(data) +assert close.flag_l == 0 +assert close.reason == 0 + += ZenohClose: dissect link close +data = bytes([0x85]) +close = ZenohClose(data) +assert close.flag_l == 1 +assert close.reason is None + + ++ ZenohKeepAlive tests + += ZenohKeepAlive: build request +ka = ZenohKeepAlive(flag_a=0) +data = raw(ka) +assert data == bytes([0x04]) + += ZenohKeepAlive: build reply +ka = ZenohKeepAlive(flag_a=1) +data = raw(ka) +assert data == bytes([0x84]) + += ZenohKeepAlive: dissect +data = bytes([0x04]) +ka = ZenohKeepAlive(data) +assert ka.mid == 4 +assert ka.flag_a == 0 + + ++ ZenohFrame tests + += ZenohFrame: build best-effort frame +frame = ZenohFrame(flag_r=0, sn=0) +data = raw(frame) +assert data == bytes([0x06, 0x00]) + += ZenohFrame: build reliable frame +frame = ZenohFrame(flag_r=1, sn=1) +data = raw(frame) +assert data == bytes([0x26, 0x01]) + += ZenohFrame: dissect +data = bytes([0x26, 0x01]) +frame = ZenohFrame(data) +assert frame.flag_r == 1 +assert frame.sn == 1 + += ZenohFrame: dispatch to Push network message +push = ZenohPush(wire_expr_id=10) +push_data = raw(push) +frame_data = raw(ZenohFrame(flag_r=1, sn=5)) + push_data +frame = ZenohFrame(frame_data) +assert ZenohPush in frame +assert frame[ZenohPush].wire_expr_id == 10 + += ZenohFrame: dispatch to Request network message +req_data = raw(ZenohRequest(rid=3, wire_expr_id=7)) +frame_data = raw(ZenohFrame(flag_r=1, sn=5)) + req_data +frame = ZenohFrame(frame_data) +assert ZenohRequest in frame +assert frame[ZenohRequest].rid == 3 + + ++ ZenohFragment tests + += ZenohFragment: build last fragment (reliable) +frag = ZenohFragment(flag_m=0, flag_r=1, sn=1) +data = raw(frag) +assert data == bytes([0x27, 0x01]) + += ZenohFragment: build more-fragments (reliable) +frag = ZenohFragment(flag_m=1, flag_r=1, sn=1) +data = raw(frag) +assert data == bytes([0xa7, 0x01]) + += ZenohFragment: dissect +data = bytes([0xa7, 0x01]) +frag = ZenohFragment(data) +assert frag.flag_m == 1 +assert frag.flag_r == 1 +assert frag.sn == 1 + + ++ ZenohJoin tests + += ZenohJoin: build without lease header byte +join = ZenohJoin(flag_t=0, what=2, zid=bytes([0x01]), resolution=512, batch_size=65535, next_sn_reliable=0, next_sn_best_effort=0) +data = raw(join) +assert data[0] == 0x08 + += ZenohJoin: build with lease header byte +join = ZenohJoin(flag_t=1, what=2, zid=bytes([0x01, 0x02]), resolution=512, batch_size=65535, lease=5000, next_sn_reliable=0, next_sn_best_effort=0) +data = raw(join) +assert data[0] == 0x48 + += ZenohJoin: dissect +data = bytes([0x48, 0x01, 0x02, 0x02, 0x01, 0x02, 0x80, 0x04, 0xff, 0xff, 0x88, 0x27, 0x00, 0x00]) +join = ZenohJoin(data) +assert join.mid == 8 +assert join.flag_t == 1 +assert join.version == 1 +assert join.what == 2 +assert join.zid == bytes([0x01, 0x02]) +assert join.resolution == 512 +assert join.batch_size == 65535 +assert join.lease == 5000 + + ++ Network Message tests + += ZenohPush: build and dissect +push = ZenohPush(wire_expr_id=10) +data = raw(push) +assert data == bytes([0x00, 0x0a]) +parsed = ZenohPush(data) +assert parsed.mid == 0 +assert parsed.wire_expr_id == 10 + += ZenohRequest: build and dissect +req = ZenohRequest(rid=1, wire_expr_id=5) +data = raw(req) +assert data == bytes([0x01, 0x01, 0x05]) +parsed = ZenohRequest(data) +assert parsed.mid == 1 +assert parsed.rid == 1 +assert parsed.wire_expr_id == 5 + += ZenohResponse: build and dissect +resp = ZenohResponse(rid=1, entity_id=2) +data = raw(resp) +assert data == bytes([0x02, 0x01, 0x02]) +parsed = ZenohResponse(data) +assert parsed.mid == 2 +assert parsed.rid == 1 +assert parsed.entity_id == 2 + += ZenohResponseFinal: build and dissect +resp_final = ZenohResponseFinal(rid=1, entity_id=2) +data = raw(resp_final) +assert data == bytes([0x03, 0x01, 0x02]) +parsed = ZenohResponseFinal(data) +assert parsed.mid == 3 +assert parsed.rid == 1 +assert parsed.entity_id == 2 + += ZenohDeclare: build header byte +decl = ZenohDeclare() +data = raw(decl) +assert data[0] == 0x05 +parsed = ZenohDeclare(data) +assert parsed.mid == 5 + += ZenohOAM: build and dissect +oam = ZenohOAM(oam_id=1) +data = raw(oam) +assert data[0] == 0x1f +parsed = ZenohOAM(data) +assert parsed.mid == 0x1f +assert parsed.oam_id == 1 + += ZenohFrame: dispatch to OAM network message +oam_data = raw(ZenohOAM(oam_id=42)) +frame_data = raw(ZenohFrame(flag_r=0, sn=0)) + oam_data +frame = ZenohFrame(frame_data) +assert ZenohOAM in frame +assert frame[ZenohOAM].oam_id == 42 + + ++ Dispatch layer tests + += ZenohScouting: dispatch Scout +data = bytes([0x01, 0x01, 0x07]) +scouting = ZenohScouting(data) +assert ZenohScout in scouting +assert scouting[ZenohScout].mid == 1 +assert scouting[ZenohScout].what == 7 + += ZenohScouting: dispatch Hello +data = bytes([0x02, 0x01, 0x02, 0x04, 0x01, 0x02, 0x03, 0x04]) +scouting = ZenohScouting(data) +assert ZenohHello in scouting +assert scouting[ZenohHello].what == 2 + += ZenohTransport: dispatch Init +data = bytes([0x00, 0x01, 0x02, 0x01, 0xab]) +transport = ZenohTransport(data) +assert ZenohInit in transport +assert transport[ZenohInit].flag_a == 0 + += ZenohTransport: dispatch Open (Ack) +data = bytes([0x81, 0x00]) +transport = ZenohTransport(data) +assert ZenohOpen in transport +assert transport[ZenohOpen].flag_a == 1 + += ZenohTransport: dispatch Close +data = bytes([0x05, 0x00]) +transport = ZenohTransport(data) +assert ZenohClose in transport + += ZenohTransport: dispatch KeepAlive +data = bytes([0x04]) +transport = ZenohTransport(data) +assert ZenohKeepAlive in transport + += ZenohTransport: dispatch Frame +data = bytes([0x26, 0x01]) +transport = ZenohTransport(data) +assert ZenohFrame in transport + += ZenohTransport: dispatch Join +data = bytes([0x08, 0x01, 0x02, 0x01, 0x01, 0x80, 0x04, 0xff, 0xff, 0x00, 0x00]) +transport = ZenohTransport(data) +assert ZenohJoin in transport + + ++ Layer binding tests + += UDP port 7446 binds to ZenohScouting +from scapy.layers.inet import UDP, IP +pkt = IP()/UDP(dport=7446)/ZenohScouting()/ZenohScout() +data = raw(pkt) +parsed = IP(data) +assert ZenohScouting in parsed + +# sport-only with default dport=53 matches the DNS binding first when all +# layers are loaded; use an explicit client port like a real Hello reply +pkt = IP()/UDP(sport=7446, dport=45678)/ZenohScouting()/ZenohScout() +data = raw(pkt) +parsed = IP(data) +assert ZenohScouting in parsed + += UDP port 7447 binds to ZenohTransport +from scapy.layers.inet import UDP, IP +pkt = IP()/UDP(dport=7447)/ZenohTransport()/ZenohInit() +data = raw(pkt) +parsed = IP(data) +assert ZenohTransport in parsed + += TCP port 7447 binds to ZenohTransport +from scapy.layers.inet import TCP, IP +pkt = IP()/TCP(dport=7447)/ZenohTransport()/ZenohInit() +data = raw(pkt) +parsed = IP(data) +assert ZenohTransport in parsed + + ++ Session handshake scenario tests + += InitSyn roundtrip +init_syn = ZenohInit(flag_a=0, flag_s=1, what=2, zid=bytes([0xaa, 0xbb, 0xcc]), resolution=512, batch_size=65535) +data = raw(init_syn) +parsed = ZenohInit(data) +assert parsed.flag_a == 0 +assert parsed.flag_s == 1 +assert parsed.resolution == 512 +assert parsed.zid == bytes([0xaa, 0xbb, 0xcc]) + += InitAck roundtrip +init_ack = ZenohInit(flag_a=1, flag_s=1, what=1, zid=bytes([0x11, 0x22]), resolution=512, batch_size=65535, nonce=0xCAFEBABE, cookie=b'secret') +data = raw(init_ack) +parsed = ZenohInit(data) +assert parsed.flag_a == 1 +assert parsed.nonce == 0xCAFEBABE +assert parsed.cookie == b'secret' + += OpenSyn roundtrip +open_syn = ZenohOpen(flag_a=0, lease=10000, initial_sn=100, cookie=b'secret') +data = raw(open_syn) +parsed = ZenohOpen(data) +assert parsed.flag_a == 0 +assert parsed.lease == 10000 +assert parsed.cookie == b'secret' + += OpenAck roundtrip +open_ack = ZenohOpen(flag_a=1, initial_sn=200) +data = raw(open_ack) +parsed = ZenohOpen(data) +assert parsed.flag_a == 1 +assert parsed.initial_sn == 200 + += Close session roundtrip +close = ZenohClose(flag_l=0, reason=1) +data = raw(close) +parsed = ZenohClose(data) +assert parsed.reason == 1 + + ++ Custom field edge cases + += ZenohVarIntField: encode None defaults to 0 +f = ZenohVarIntField('test', 0) +assert f.addfield(None, b'', None) == bytes([0]) + += ZenohVarIntField: i2repr and randval +f = ZenohVarIntField('test', 0) +assert f.i2repr(None, 42) == '42' +assert 0 <= f.randval() <= 0xFFFF + += ZenohVarIntField: incomplete varint consumes all input +f = ZenohVarIntField('test', 0) +remainder, decoded = f.getfield(None, bytes([0x80, 0x80])) +assert decoded == 0 +assert remainder == b'' + += ZenohIDField: encode string value +f = ZenohIDField('zid', b'') +encoded = f.addfield(None, b'', 'ab') +assert encoded == bytes([2]) + b'ab' + += ZenohIDField: getfield on empty buffer +f = ZenohIDField('zid', b'') +assert f.getfield(None, b'') == (b'', b'') + += ZenohIDField: i2repr and conversion helpers +f = ZenohIDField('zid', b'') +assert f.i2repr(None, b'\x01\x02') == '0102' +assert f.i2repr(None, 'not-bytes') == '' +assert f.i2h(None, None) == b'' +assert f.h2i(None, None) == b'' +assert f.h2i(None, '0102') == bytes([1, 2]) +assert f.h2i(None, 'not-hex') == b'not-hex' + += ZenohBytesField: encode and decode payload +f = ZenohBytesField('cookie', b'') +payload = b'secret' +enc = f.addfield(None, b'', payload) +_, dec = f.getfield(None, enc) +assert dec == payload + += ZenohBytesField: encode string and None +f = ZenohBytesField('cookie', b'') +assert f.addfield(None, b'', None) == bytes([0]) +assert f.addfield(None, b'', 'hi') == bytes([2]) + b'hi' + += ZenohBytesField: getfield on empty buffer +f = ZenohBytesField('cookie', b'') +assert f.getfield(None, b'') == (b'', b'') + += ZenohBytesField: i2repr non-bytes +f = ZenohBytesField('cookie', b'') +assert f.i2repr(None, b'\xab\xcd') == 'abcd' +assert f.i2repr(None, 123) == '' + + ++ guess_payload_class tests + += ZenohScout: empty payload returns padding +from scapy.config import conf +assert ZenohScout().guess_payload_class(b'') is conf.padding_layer + += ZenohHello: empty payload returns padding +assert ZenohHello().guess_payload_class(b'') is conf.padding_layer + += ZenohInit: empty payload returns padding +assert ZenohInit().guess_payload_class(b'') is conf.padding_layer + += ZenohOpen: empty payload returns padding +assert ZenohOpen().guess_payload_class(b'') is conf.padding_layer + += ZenohClose: empty payload returns padding +assert ZenohClose().guess_payload_class(b'') is conf.padding_layer + += ZenohKeepAlive: empty payload returns padding +assert ZenohKeepAlive().guess_payload_class(b'') is conf.padding_layer + += ZenohJoin: empty payload returns padding +assert ZenohJoin().guess_payload_class(b'') is conf.padding_layer + += ZenohFrame: empty payload returns padding +assert ZenohFrame().guess_payload_class(b'') is conf.padding_layer + += ZenohFrame: unknown network MID returns Raw +from scapy.packet import Raw +assert ZenohFrame().guess_payload_class(bytes([0x1e])) is Raw + += ZenohFrame: dispatch Response network message +resp_data = raw(ZenohResponse(rid=2, entity_id=3)) +frame = ZenohFrame(raw(ZenohFrame(flag_r=0, sn=0)) + resp_data) +assert ZenohResponse in frame +assert frame[ZenohResponse].entity_id == 3 + += ZenohFrame: dispatch ResponseFinal network message +resp_final_data = raw(ZenohResponseFinal(rid=4, entity_id=5)) +frame = ZenohFrame(raw(ZenohFrame(flag_r=0, sn=0)) + resp_final_data) +assert ZenohResponseFinal in frame + += ZenohFrame: dispatch Declare network message +decl_data = raw(ZenohDeclare()) +frame = ZenohFrame(raw(ZenohFrame(flag_r=0, sn=0)) + decl_data) +assert ZenohDeclare in frame + += ZenohNetworkMsg: empty payload returns padding +assert ZenohNetworkMsg().guess_payload_class(b'') is conf.padding_layer + += ZenohNetworkMsg: dispatch Push network message +net = ZenohNetworkMsg(raw(ZenohPush(wire_expr_id=11))) +assert ZenohPush in net +assert net[ZenohPush].wire_expr_id == 11 + += ZenohNetworkMsg: unknown MID returns Raw +assert ZenohNetworkMsg().guess_payload_class(bytes([0x1e])) is Raw + += ZenohScouting: empty payload returns padding +assert ZenohScouting().guess_payload_class(b'') is conf.padding_layer + += ZenohScouting: unknown MID returns Raw +assert ZenohScouting().guess_payload_class(bytes([0x1f])) is Raw + += ZenohTransport: empty payload returns padding +assert ZenohTransport().guess_payload_class(b'') is conf.padding_layer + += ZenohTransport: unknown MID returns Raw +assert ZenohTransport().guess_payload_class(bytes([0x1f])) is Raw + += ZenohTransport: dispatch Fragment +frag_data = bytes([0x27, 0x01]) +transport = ZenohTransport(frag_data) +assert ZenohFragment in transport + + ++ Additional layer binding tests + += UDP sport 7447 binds to ZenohTransport +from scapy.layers.inet import UDP, IP +pkt = IP()/UDP(sport=7447, dport=45678)/ZenohTransport()/ZenohKeepAlive() +parsed = IP(raw(pkt)) +assert ZenohTransport in parsed + += TCP sport 7447 binds to ZenohTransport +from scapy.layers.inet import TCP, IP +pkt = IP()/TCP(sport=7447, dport=45678)/ZenohTransport()/ZenohKeepAlive() +parsed = IP(raw(pkt)) +assert ZenohTransport in parsed + + ++ Additional message variant tests + += ZenohPush: NoSubscribers flag +push = ZenohPush(flag_n=1, wire_expr_id=1) +assert raw(push)[0] == 0x80 + += ZenohKeepAlive: dissect reply +ka = ZenohKeepAlive(bytes([0x84])) +assert ka.flag_a == 1 + += ZenohFragment: best-effort fragment +frag = ZenohFragment(flag_m=0, flag_r=0, sn=2) +assert raw(frag) == bytes([0x07, 0x02]) + += ZenohJoin: build without lease omits lease field +join = ZenohJoin(flag_t=0, what=2, zid=bytes([0x01]), resolution=512, batch_size=65535, next_sn_reliable=0, next_sn_best_effort=0) +parsed = ZenohJoin(raw(join)) +assert parsed.lease is None + += ZenohClose: reason codes +for reason in [1, 2, 3, 4]: + close = ZenohClose(flag_l=0, reason=reason) + assert ZenohClose(raw(close)).reason == reason