diff --git a/README.md b/README.md index 0eae7d1..97a955b 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,37 @@ terminal wakeup but closes the established socket immediately, without waiting for close-notify. All three methods are idempotent; a quiesced or aborted session cannot be connected again. +## CoAP over TCP framing + +`smartthings_local.protocol.coap_tcp` provides the pure reliable-transport +wire codec used by Samsung's IoTivity stack. It follows the variable-length +header defined by [RFC 8323](https://www.rfc-editor.org/rfc/rfc8323.html) and +corroborated by Samsung's public IoTivity 1.2 sources for +[`CAGeneratePDUImpl`](https://github.com/Samsung/TizenRT/blob/0df9b54dfd35d9aaba2c16eb2ef9f4b4b6a5f545/external/iotivity/iotivity_1.2-rel/resource/csdk/connectivity/src/caprotocolmessage.c) +and +[`coap_get_total_message_length`](https://github.com/Samsung/TizenRT/blob/0df9b54dfd35d9aaba2c16eb2ef9f4b4b6a5f545/external/iotivity/iotivity_1.2-rel/resource/csdk/connectivity/lib/libcoap-4.1.1/pdu.c). + +Builders cover the opening CSM, raw messages, and GET, POST, and DELETE +convenience forms. The CSM builder can advertise Max-Message-Size and +Block-Wise-Transfer without owning connection policy. +`CoapTcpStreamDecoder` accepts partial or concatenated byte-stream chunks, +emits only complete parsed messages, and rejects an oversized declaration as +soon as its length prefix is available: + +```python +from smartthings_local.protocol.coap_tcp import ( + CoapTcpStreamDecoder, + build_coap_tcp_get, +) + +request = build_coap_tcp_get("/oic/res", token=b"\x01") +decoder = CoapTcpStreamDecoder(max_message_size=64 * 1024) +messages = decoder.feed(received_chunk) +``` + +The module deliberately does not open a TCP/TLS/Bluetooth connection, choose a +carrier, or perform setup and ownership operations. Those remain caller policy. + Reads retransmit each Block2 request; writes send once. Where a lost write has been shown to be the cause rather than a device that is simply refusing load, `write_max_attempts` lets `post()` retransmit inside the caller's own diff --git a/smartthings_local/protocol/coap_tcp.py b/smartthings_local/protocol/coap_tcp.py new file mode 100644 index 0000000..9c46114 --- /dev/null +++ b/smartthings_local/protocol/coap_tcp.py @@ -0,0 +1,609 @@ +"""Pure CoAP-over-TCP wire codec used by Samsung's IoTivity stack. + +IoTivity routes its TCP, GATT-BTLE, and RFCOMM adapters through the same +reliable-transport CoAP serializer. The variable-length header follows the +format later standardized by RFC 8323: ``Len`` counts only encoded options and +the optional payload marker/payload. Code and Token follow the length field +but are not included in ``Len``. + +The framing behavior is also present in Samsung's public TizenRT copy of +IoTivity 1.2, notably ``CAGeneratePDUImpl``, ``coap_add_length``, and +``coap_get_total_message_length``. This module implements only that pure wire +format. It performs no socket, TLS, Bluetooth, account, or appliance I/O. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Final + +from .coap import ( + ACCEPT, + CONTENT_FORMAT, + METHOD_DELETE, + METHOD_GET, + METHOD_POST, + URI_PATH, + URI_QUERY, +) + +__all__ = [ + "CoapTcpCodecError", + "CoapTcpMessage", + "CoapTcpStreamDecoder", + "build_coap_tcp_csm", + "build_coap_tcp_delete", + "build_coap_tcp_get", + "build_coap_tcp_message", + "build_coap_tcp_post", + "encode_uint_option", + "parse_coap_tcp_message", +] + +COAP_TCP_SHORT_LENGTH_LIMIT: Final = 13 +COAP_TCP_8BIT_LENGTH_BASE: Final = 13 +COAP_TCP_16BIT_LENGTH_BASE: Final = 269 +COAP_TCP_32BIT_LENGTH_BASE: Final = 65_805 +COAP_TCP_MAX_DECLARED_LENGTH: Final = 0xFFFFFFFF + COAP_TCP_32BIT_LENGTH_BASE +COAP_MAX_TOKEN_LENGTH: Final = 8 +COAP_TCP_MAX_MESSAGE_SIZE: Final = ( + COAP_TCP_MAX_DECLARED_LENGTH + 5 + 1 + COAP_MAX_TOKEN_LENGTH +) +COAP_MAX_OPTION_NUMBER: Final = 0xFFFF +COAP_MAX_OPTION_VALUE_LENGTH: Final = 65_804 +DEFAULT_MAX_MESSAGE_SIZE: Final = 4 * 1024 * 1024 +PAYLOAD_MARKER: Final = 0xFF +CSM_CODE: Final = 0xE1 +CSM_MAX_MESSAGE_SIZE_OPTION: Final = 2 +CSM_BLOCK_WISE_TRANSFER_OPTION: Final = 4 + + +class CoapTcpCodecError(ValueError): + """An invalid or unsupported CoAP-over-TCP message.""" + + +@dataclass(frozen=True, slots=True, repr=False) +class CoapTcpMessage: + """One decoded CoAP-over-TCP message.""" + + code: int + token: bytes + options: tuple[tuple[int, bytes], ...] + payload: bytes + + def __repr__(self) -> str: + """Return metadata without exposing token, option, or payload bytes.""" + return ( + "CoapTcpMessage(" + f"code={self.code!r}, option_count={len(self.options)}, " + f"payload_length={len(self.payload)})" + ) + + +def _coerce_bytes( + value: object, + *, + name: str, + max_length: int | None = None, +) -> bytes: + if not isinstance(value, (bytes, bytearray, memoryview)): + raise TypeError(f"{name} must be bytes-like") + if max_length is not None and len(value) > max_length: + raise CoapTcpCodecError( + f"{name} length {len(value)} exceeds maximum {max_length}" + ) + return bytes(value) + + +def _validate_int( + value: object, + *, + name: str, + minimum: int, + maximum: int, +) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{name} must be an integer") + if not minimum <= value <= maximum: + raise CoapTcpCodecError( + f"{name} must be in the range {minimum}..{maximum}" + ) + return value + + +def _validate_max_message_size(value: object) -> int: + return _validate_int( + value, + name="max_message_size", + minimum=2, + maximum=COAP_TCP_MAX_MESSAGE_SIZE, + ) + + +def encode_uint_option(value: int) -> bytes: + """Encode a non-negative CoAP uint option in its shortest form.""" + value = _validate_int( + value, + name="option integer", + minimum=0, + maximum=0xFFFFFFFF, + ) + if value == 0: + return b"" + return value.to_bytes((value.bit_length() + 7) // 8, "big") + + +def _encode_extended(value: int, *, name: str) -> tuple[int, bytes]: + if value < 13: + return value, b"" + if value < 269: + return 13, bytes((value - 13,)) + if value <= COAP_MAX_OPTION_VALUE_LENGTH: + return 14, (value - 269).to_bytes(2, "big") + raise CoapTcpCodecError(f"{name} is too large for a CoAP option header") + + +def _normalize_options( + options: Iterable[tuple[int, bytes | bytearray | memoryview]], +) -> tuple[tuple[int, bytes], ...]: + normalized: list[tuple[int, bytes]] = [] + for index, item in enumerate(options): + if not isinstance(item, (tuple, list)) or len(item) != 2: + raise TypeError(f"option {index} must be a (number, value) pair") + number = _validate_int( + item[0], + name=f"option {index} number", + minimum=0, + maximum=COAP_MAX_OPTION_NUMBER, + ) + value = _coerce_bytes( + item[1], + name=f"option {index} value", + max_length=COAP_MAX_OPTION_VALUE_LENGTH, + ) + normalized.append((number, value)) + return tuple(sorted(normalized, key=lambda item: item[0])) + + +def _encode_options(options: tuple[tuple[int, bytes], ...]) -> bytes: + encoded = bytearray() + previous = 0 + for number, value in options: + delta_nibble, delta_extra = _encode_extended( + number - previous, + name="option delta", + ) + length_nibble, length_extra = _encode_extended( + len(value), + name="option length", + ) + encoded.append((delta_nibble << 4) | length_nibble) + encoded.extend(delta_extra) + encoded.extend(length_extra) + encoded.extend(value) + previous = number + return bytes(encoded) + + +def _encode_length(option_payload_length: int, token_length: int) -> bytes: + if option_payload_length < COAP_TCP_SHORT_LENGTH_LIMIT: + return bytes(((option_payload_length << 4) | token_length,)) + if option_payload_length < COAP_TCP_16BIT_LENGTH_BASE: + return bytes( + ( + (13 << 4) | token_length, + option_payload_length - COAP_TCP_8BIT_LENGTH_BASE, + ) + ) + if option_payload_length < COAP_TCP_32BIT_LENGTH_BASE: + return bytes(((14 << 4) | token_length,)) + ( + option_payload_length - COAP_TCP_16BIT_LENGTH_BASE + ).to_bytes(2, "big") + if option_payload_length <= COAP_TCP_MAX_DECLARED_LENGTH: + return bytes(((15 << 4) | token_length,)) + ( + option_payload_length - COAP_TCP_32BIT_LENGTH_BASE + ).to_bytes(4, "big") + raise CoapTcpCodecError( + "declared CoAP-over-TCP length exceeds its wire field" + ) + + +def build_coap_tcp_message( + *, + code: int, + token: bytes | bytearray | memoryview = b"", + options: Iterable[tuple[int, bytes | bytearray | memoryview]] = (), + payload: bytes | bytearray | memoryview = b"", + max_message_size: int = DEFAULT_MAX_MESSAGE_SIZE, +) -> bytes: + """Build one bounded CoAP-over-TCP wire message.""" + maximum = _validate_max_message_size(max_message_size) + code = _validate_int(code, name="code", minimum=0, maximum=0xFF) + token_bytes = _coerce_bytes( + token, + name="token", + max_length=COAP_MAX_TOKEN_LENGTH, + ) + payload_bytes = _coerce_bytes( + payload, + name="payload", + max_length=maximum, + ) + encoded_options = _encode_options(_normalize_options(options)) + option_payload_length = len(encoded_options) + if payload_bytes: + option_payload_length += 1 + len(payload_bytes) + + prefix = _encode_length(option_payload_length, len(token_bytes)) + wire_size = len(prefix) + 1 + len(token_bytes) + option_payload_length + if wire_size > maximum: + raise CoapTcpCodecError( + f"CoAP-over-TCP message length {wire_size} exceeds maximum " + f"{maximum}" + ) + + wire = prefix + bytes((code,)) + token_bytes + encoded_options + if payload_bytes: + wire += bytes((PAYLOAD_MARKER,)) + payload_bytes + return wire + + +def _path_options( + path: str, + query: Iterable[str], +) -> list[tuple[int, bytes]]: + if not isinstance(path, str): + raise TypeError("path must be a string") + if not path.startswith("/") or "?" in path or "#" in path: + raise CoapTcpCodecError( + "path must be an absolute href without query or fragment" + ) + if isinstance(query, (str, bytes, bytearray, memoryview)): + raise TypeError("query must be an iterable of strings") + + options = [ + (URI_PATH, segment.encode("utf-8")) + for segment in path.split("/") + if segment + ] + for index, value in enumerate(query): + if not isinstance(value, str): + raise TypeError(f"query item {index} must be a string") + if not value: + raise CoapTcpCodecError(f"query item {index} must not be empty") + options.append((URI_QUERY, value.encode("utf-8"))) + return options + + +def build_coap_tcp_get( + path: str, + *, + token: bytes | bytearray | memoryview = b"", + query: Iterable[str] = (), + accept: int | None = None, + extra_options: Iterable[ + tuple[int, bytes | bytearray | memoryview] + ] = (), + max_message_size: int = DEFAULT_MAX_MESSAGE_SIZE, +) -> bytes: + """Build a GET with repeated Uri-Path and Uri-Query options.""" + options = _path_options(path, query) + if accept is not None: + options.append((ACCEPT, encode_uint_option(accept))) + options.extend(extra_options) + return build_coap_tcp_message( + code=METHOD_GET, + token=token, + options=options, + max_message_size=max_message_size, + ) + + +def build_coap_tcp_post( + path: str, + payload: bytes | bytearray | memoryview, + *, + token: bytes | bytearray | memoryview = b"", + query: Iterable[str] = (), + content_format: int = 60, + accept: int | None = 60, + extra_options: Iterable[ + tuple[int, bytes | bytearray | memoryview] + ] = (), + max_message_size: int = DEFAULT_MAX_MESSAGE_SIZE, +) -> bytes: + """Build a POST with one bounded payload for an absolute OCF href.""" + options = _path_options(path, query) + options.append((CONTENT_FORMAT, encode_uint_option(content_format))) + if accept is not None: + options.append((ACCEPT, encode_uint_option(accept))) + options.extend(extra_options) + return build_coap_tcp_message( + code=METHOD_POST, + token=token, + options=options, + payload=payload, + max_message_size=max_message_size, + ) + + +def build_coap_tcp_csm( + *, + receive_max_message_size: int | None = None, + block_wise_transfer: bool = False, + max_message_size: int = DEFAULT_MAX_MESSAGE_SIZE, +) -> bytes: + """Build an RFC 8323 Capabilities and Settings Message. + + ``receive_max_message_size`` advertises the largest complete wire message + this endpoint accepts. Omitting it retains RFC 8323's 1152-byte base value. + ``block_wise_transfer`` advertises RFC 7959 support; paired with a value + above 1152 it also advertises BERT support. + """ + if not isinstance(block_wise_transfer, bool): + raise TypeError("block_wise_transfer must be a boolean") + options: list[tuple[int, bytes]] = [] + if receive_max_message_size is not None: + receive_max_message_size = _validate_int( + receive_max_message_size, + name="receive_max_message_size", + minimum=2, + maximum=0xFFFFFFFF, + ) + options.append( + ( + CSM_MAX_MESSAGE_SIZE_OPTION, + encode_uint_option(receive_max_message_size), + ) + ) + if block_wise_transfer: + options.append((CSM_BLOCK_WISE_TRANSFER_OPTION, b"")) + return build_coap_tcp_message( + code=CSM_CODE, + options=options, + max_message_size=max_message_size, + ) + + +def build_coap_tcp_delete( + path: str, + *, + token: bytes | bytearray | memoryview = b"", + query: Iterable[str] = (), + accept: int | None = None, + extra_options: Iterable[ + tuple[int, bytes | bytearray | memoryview] + ] = (), + max_message_size: int = DEFAULT_MAX_MESSAGE_SIZE, +) -> bytes: + """Build a DELETE for one absolute OCF href and bounded query.""" + options = _path_options(path, query) + if accept is not None: + options.append((ACCEPT, encode_uint_option(accept))) + options.extend(extra_options) + return build_coap_tcp_message( + code=METHOD_DELETE, + token=token, + options=options, + max_message_size=max_message_size, + ) + + +def _decode_length(data: bytes | bytearray | memoryview) -> tuple[int, int, int]: + if not data: + raise CoapTcpCodecError("CoAP-over-TCP message is empty") + first = data[0] + length_nibble = first >> 4 + token_length = first & 0x0F + if token_length > COAP_MAX_TOKEN_LENGTH: + raise CoapTcpCodecError("CoAP token length exceeds 8 bytes") + if length_nibble < 13: + return length_nibble, token_length, 1 + + extension_size = {13: 1, 14: 2, 15: 4}[length_nibble] + prefix_size = 1 + extension_size + if len(data) < prefix_size: + raise CoapTcpCodecError("truncated CoAP-over-TCP length field") + extension = int.from_bytes(data[1:prefix_size], "big") + base = { + 13: COAP_TCP_8BIT_LENGTH_BASE, + 14: COAP_TCP_16BIT_LENGTH_BASE, + 15: COAP_TCP_32BIT_LENGTH_BASE, + }[length_nibble] + return base + extension, token_length, prefix_size + + +def _frame_size_from_prefix( + data: bytes | bytearray | memoryview, + *, + maximum: int, +) -> int | None: + """Return one frame size, or ``None`` until its length field is complete.""" + if not data: + return None + first = data[0] + token_length = first & 0x0F + if token_length > COAP_MAX_TOKEN_LENGTH: + raise CoapTcpCodecError("CoAP token length exceeds 8 bytes") + length_nibble = first >> 4 + extension_size = 0 if length_nibble < 13 else {13: 1, 14: 2, 15: 4}[ + length_nibble + ] + prefix_size = 1 + extension_size + if len(data) < prefix_size: + return None + option_payload_length, _, _ = _decode_length(data[:prefix_size]) + frame_size = prefix_size + 1 + token_length + option_payload_length + if frame_size > maximum: + raise CoapTcpCodecError( + f"declared CoAP-over-TCP message length {frame_size} exceeds " + f"maximum {maximum}" + ) + return frame_size + + +def _decode_extended( + data: bytes, + cursor: int, + nibble: int, +) -> tuple[int, int]: + if nibble < 13: + return nibble, cursor + if nibble == 15: + raise CoapTcpCodecError("reserved CoAP option nibble 15") + extension_size = 1 if nibble == 13 else 2 + end = cursor + extension_size + if end > len(data): + raise CoapTcpCodecError("truncated CoAP option extension") + base = 13 if nibble == 13 else 269 + return base + int.from_bytes(data[cursor:end], "big"), end + + +def _decode_options( + data: bytes, +) -> tuple[tuple[tuple[int, bytes], ...], bytes]: + options: list[tuple[int, bytes]] = [] + previous = 0 + cursor = 0 + while cursor < len(data): + first = data[cursor] + cursor += 1 + if first == PAYLOAD_MARKER: + if cursor == len(data): + raise CoapTcpCodecError("CoAP payload marker has no payload") + return tuple(options), data[cursor:] + + delta, cursor = _decode_extended(data, cursor, first >> 4) + length, cursor = _decode_extended(data, cursor, first & 0x0F) + number = previous + delta + if number > COAP_MAX_OPTION_NUMBER: + raise CoapTcpCodecError( + "decoded CoAP option number exceeds 65535" + ) + end = cursor + length + if end > len(data): + raise CoapTcpCodecError("truncated CoAP option value") + options.append((number, data[cursor:end])) + cursor = end + previous = number + return tuple(options), b"" + + +def parse_coap_tcp_message( + data: bytes | bytearray | memoryview, + *, + max_message_size: int = DEFAULT_MAX_MESSAGE_SIZE, +) -> CoapTcpMessage: + """Parse exactly one complete bounded CoAP-over-TCP message.""" + maximum = _validate_max_message_size(max_message_size) + wire = _coerce_bytes(data, name="data", max_length=maximum) + + option_payload_length, token_length, prefix_size = _decode_length(wire) + expected_size = prefix_size + 1 + token_length + option_payload_length + if expected_size > maximum: + raise CoapTcpCodecError( + f"declared CoAP-over-TCP message length {expected_size} exceeds " + f"maximum {maximum}" + ) + if len(wire) < expected_size: + raise CoapTcpCodecError("truncated CoAP-over-TCP message body") + if len(wire) > expected_size: + raise CoapTcpCodecError("trailing bytes after CoAP-over-TCP message") + + code_offset = prefix_size + token_offset = code_offset + 1 + option_offset = token_offset + token_length + options, payload = _decode_options(wire[option_offset:]) + return CoapTcpMessage( + code=wire[code_offset], + token=wire[token_offset:option_offset], + options=options, + payload=payload, + ) + + +class CoapTcpStreamDecoder: + """Incrementally split and parse CoAP messages from a byte stream. + + Only one incomplete frame is buffered. Complete concatenated frames are + emitted immediately, so a large read containing many small valid messages + is not treated as one oversized message. + """ + + def __init__( + self, + *, + max_message_size: int = DEFAULT_MAX_MESSAGE_SIZE, + ) -> None: + self._max_message_size = _validate_max_message_size(max_message_size) + self._buffer = bytearray() + + @property + def buffered_bytes(self) -> int: + """Return the number of bytes retained for one incomplete frame.""" + return len(self._buffer) + + def reset(self) -> None: + """Discard an incomplete frame.""" + self._buffer.clear() + + def feed( + self, + data: bytes | bytearray | memoryview, + ) -> tuple[CoapTcpMessage, ...]: + """Consume a stream chunk and return every complete message in it.""" + chunk = _coerce_bytes(data, name="data") + messages: list[CoapTcpMessage] = [] + offset = 0 + try: + while offset < len(chunk): + if self._buffer: + frame_size = _frame_size_from_prefix( + self._buffer, + maximum=self._max_message_size, + ) + if frame_size is None: + self._buffer.append(chunk[offset]) + offset += 1 + continue + take = min( + frame_size - len(self._buffer), + len(chunk) - offset, + ) + self._buffer.extend(chunk[offset:offset + take]) + offset += take + if len(self._buffer) < frame_size: + break + frame = bytes(self._buffer) + self._buffer.clear() + else: + remaining = memoryview(chunk)[offset:] + frame_size = _frame_size_from_prefix( + remaining, + maximum=self._max_message_size, + ) + if frame_size is None or len(remaining) < frame_size: + self._buffer.extend(remaining) + break + frame = bytes(remaining[:frame_size]) + offset += frame_size + + messages.append( + parse_coap_tcp_message( + frame, + max_message_size=self._max_message_size, + ) + ) + except (CoapTcpCodecError, TypeError): + self.reset() + raise + return tuple(messages) + + def finish(self) -> None: + """Accept end-of-stream only when no partial message remains.""" + if self._buffer: + self.reset() + raise CoapTcpCodecError( + "truncated CoAP-over-TCP message at end of stream" + ) diff --git a/tests/test_coap_tcp.py b/tests/test_coap_tcp.py new file mode 100644 index 0000000..cd16d3f --- /dev/null +++ b/tests/test_coap_tcp.py @@ -0,0 +1,363 @@ +"""CoAP-over-TCP framing and stream-decoding contracts.""" + +from __future__ import annotations + +import pytest + +from smartthings_local.protocol.coap_tcp import ( + COAP_TCP_MAX_MESSAGE_SIZE, + CoapTcpCodecError, + CoapTcpMessage, + CoapTcpStreamDecoder, + build_coap_tcp_csm, + build_coap_tcp_delete, + build_coap_tcp_get, + build_coap_tcp_message, + build_coap_tcp_post, + encode_uint_option, + parse_coap_tcp_message, +) + + +def test_delete_round_trip_preserves_repeated_query_and_extension_options(): + wire = build_coap_tcp_delete( + "/oic/sec/cred", + token=b"\x12\x34", + query=("credid=7", "credid=19"), + extra_options=((65524, b"\xc0"),), + ) + + message = parse_coap_tcp_message(wire) + + assert message == CoapTcpMessage( + code=0x04, + token=b"\x12\x34", + options=( + (11, b"oic"), + (11, b"sec"), + (11, b"cred"), + (15, b"credid=7"), + (15, b"credid=19"), + (65524, b"\xc0"), + ), + payload=b"", + ) + + +def test_post_round_trip_uses_query_cbor_content_format_and_accept(): + wire = build_coap_tcp_post( + "/oic/sec/doxm", + b"\xa1eowned\xf5", + token=b"\x11\x22", + query=("if=oic.if.rw",), + ) + + message = parse_coap_tcp_message(wire) + + assert message.code == 0x02 + assert message.token == b"\x11\x22" + assert message.options == ( + (11, b"oic"), + (11, b"sec"), + (11, b"doxm"), + (12, b"<"), + (15, b"if=oic.if.rw"), + (17, b"<"), + ) + assert message.payload == b"\xa1eowned\xf5" + + +def test_rfc8323_length_excludes_code_and_token(): + wire = build_coap_tcp_get("/oic/res", token=b"\xaa\xbb") + + # Len=8 is exactly the two Uri-Path option encodings. Code and Token + # follow the length field but are not included in the declared length. + assert wire == bytes.fromhex("82 01 aa bb b3 6f6963 03 726573") + assert parse_coap_tcp_message(wire) == CoapTcpMessage( + code=0x01, + token=b"\xaa\xbb", + options=((11, b"oic"), (11, b"res")), + payload=b"", + ) + + assert build_coap_tcp_message(code=0x01) == b"\x00\x01" + with pytest.raises(CoapTcpCodecError, match="truncated"): + parse_coap_tcp_message(b"\x10\x01") + + +def test_source_derived_ocf_response_vector_round_trips(): + wire = bytes.fromhex("81 45 aa c2 2710 ff a1617801") + message = CoapTcpMessage( + code=0x45, + token=b"\xaa", + options=((12, bytes.fromhex("2710")),), + payload=bytes.fromhex("a1617801"), + ) + + assert parse_coap_tcp_message(wire) == message + assert build_coap_tcp_message( + code=message.code, + token=message.token, + options=message.options, + payload=message.payload, + ) == wire + + +def test_iotivity_csm_vector_uses_reliable_transport_header(): + assert build_coap_tcp_csm( + receive_max_message_size=1152, + ) == bytes.fromhex("30 e1 22 0480") + + message = parse_coap_tcp_message( + build_coap_tcp_csm( + receive_max_message_size=4096, + block_wise_transfer=True, + ) + ) + assert message == CoapTcpMessage( + code=0xE1, + token=b"", + options=((2, b"\x10\x00"), (4, b"")), + payload=b"", + ) + + +def test_empty_csm_uses_rfc_base_capabilities(): + assert build_coap_tcp_csm() == b"\x00\xe1" + + +@pytest.mark.parametrize( + ("declared_length", "prefix"), + ( + (0, bytes.fromhex("00")), + (12, bytes.fromhex("c0")), + (13, bytes.fromhex("d0 00")), + (268, bytes.fromhex("d0 ff")), + (269, bytes.fromhex("e0 0000")), + (65_804, bytes.fromhex("e0 ffff")), + (65_805, bytes.fromhex("f0 00000000")), + ), +) +def test_iotivity_length_field_boundaries(declared_length, prefix): + payload = b"" if declared_length == 0 else b"x" * (declared_length - 1) + + wire = build_coap_tcp_message( + code=0x45, + payload=payload, + max_message_size=70_000, + ) + + assert wire[:len(prefix)] == prefix + assert parse_coap_tcp_message( + wire, + max_message_size=70_000, + ).payload == payload + + +def test_get_builder_encodes_query_accept_utf8_and_extra_options(): + wire = build_coap_tcp_get( + "/a/temperature", + token=memoryview(b"12345678"), + query=("if=oic.if.a", "lang=caf\N{LATIN SMALL LETTER E WITH ACUTE}"), + accept=60, + extra_options=((6, b""),), + ) + + message = parse_coap_tcp_message(wire) + + assert message.code == 0x01 + assert message.token == b"12345678" + assert message.options == ( + (6, b""), + (11, b"a"), + (11, b"temperature"), + (15, b"if=oic.if.a"), + (15, "lang=caf\N{LATIN SMALL LETTER E WITH ACUTE}".encode()), + (17, b"<"), + ) + + +def test_extended_options_and_duplicate_numbers_round_trip(): + options = ( + (13, b"a" * 13), + (13, b""), + (300, b"z" * 269), + (65_535, b"tail"), + ) + + wire = build_coap_tcp_message(code=0x45, options=options) + + assert parse_coap_tcp_message(wire).options == options + + +@pytest.mark.parametrize( + ("wire", "error"), + ( + (b"", "empty"), + (b"\xd0", "length field"), + (b"\x00", "body"), + (b"\x10\x01", "body"), + (b"\x00\x01\x00", "trailing"), + (b"\x10\x45\xff", "marker"), + (b"\x10\x45\xf0", "nibble"), + (b"\x10\x45\xd0", "extension"), + (b"\x10\x45\x01", "value"), + (b"\x09\x01" + b"x" * 9, "token length"), + ), +) +def test_parser_rejects_truncation_trailing_and_invalid_options(wire, error): + with pytest.raises(CoapTcpCodecError, match=error): + parse_coap_tcp_message(wire) + + +def test_declared_and_actual_message_bounds_fail_closed(): + with pytest.raises(CoapTcpCodecError, match="declared.*exceeds"): + parse_coap_tcp_message( + bytes.fromhex("f0 00000000 45"), + max_message_size=100, + ) + with pytest.raises(CoapTcpCodecError, match="exceeds maximum"): + build_coap_tcp_message( + code=1, + payload=b"x" * 20, + max_message_size=10, + ) + assert build_coap_tcp_message( + code=1, + max_message_size=COAP_TCP_MAX_MESSAGE_SIZE, + ) == b"\x00\x01" + with pytest.raises(CoapTcpCodecError, match="max_message_size"): + build_coap_tcp_message( + code=1, + max_message_size=COAP_TCP_MAX_MESSAGE_SIZE + 1, + ) + + +def test_builder_rejects_unsafe_or_ambiguous_inputs(): + with pytest.raises(CoapTcpCodecError): + build_coap_tcp_message(code=256) + with pytest.raises(CoapTcpCodecError): + build_coap_tcp_message(code=1, token=b"123456789") + with pytest.raises(TypeError): + build_coap_tcp_message(code=True) + with pytest.raises(TypeError): + build_coap_tcp_message(code=1, token="not-bytes") + with pytest.raises(TypeError): + build_coap_tcp_message(code=1, options=((11, "not-bytes"),)) + with pytest.raises(CoapTcpCodecError): + build_coap_tcp_message(code=1, options=((65_536, b"x"),)) + with pytest.raises(CoapTcpCodecError, match="option 0 value"): + build_coap_tcp_message(code=1, options=((11, memoryview(b"x" * 65_805)),)) + + for path in ("relative", "/has?query", "/has#fragment"): + with pytest.raises(CoapTcpCodecError): + build_coap_tcp_get(path) + with pytest.raises(TypeError): + build_coap_tcp_get("/path", query="if=oic.if.a") + with pytest.raises(TypeError): + build_coap_tcp_get("/path", query=(1,)) + with pytest.raises(CoapTcpCodecError): + build_coap_tcp_get("/path", query=("",)) + with pytest.raises(TypeError): + build_coap_tcp_csm(block_wise_transfer=1) + with pytest.raises(CoapTcpCodecError): + build_coap_tcp_csm(receive_max_message_size=1) + with pytest.raises(CoapTcpCodecError): + build_coap_tcp_csm(receive_max_message_size=0x1_0000_0000) + + +def test_uint_option_uses_minimal_network_order(): + assert encode_uint_option(0) == b"" + assert encode_uint_option(60) == b"\x3c" + assert encode_uint_option(10_000) == bytes.fromhex("2710") + with pytest.raises(CoapTcpCodecError): + encode_uint_option(-1) + + +def test_message_repr_does_not_expose_wire_values(): + message = CoapTcpMessage( + code=0x45, + token=b"token-not-for-repr", + options=((11, b"option-not-for-repr"),), + payload=b"payload-not-for-repr", + ) + + rendered = repr(message) + + assert rendered == "CoapTcpMessage(code=69, option_count=1, payload_length=20)" + assert "token-not-for-repr" not in rendered + assert "option-not-for-repr" not in rendered + assert "payload-not-for-repr" not in rendered + + +def test_stream_decoder_accepts_every_two_chunk_split_and_concatenation(): + wires = ( + build_coap_tcp_get("/oic/res", token=b"a"), + build_coap_tcp_post("/mode/vs/0", b"payload", token=b"bc"), + build_coap_tcp_delete("/oic/sec/cred", token=b"def"), + ) + stream = b"".join(wires) + expected = tuple(parse_coap_tcp_message(wire) for wire in wires) + + for split in range(len(stream) + 1): + decoder = CoapTcpStreamDecoder() + messages = decoder.feed(stream[:split]) + decoder.feed(stream[split:]) + decoder.finish() + assert messages == expected + assert decoder.buffered_bytes == 0 + + +def test_stream_decoder_accepts_bytewise_extended_length_input(): + wire = build_coap_tcp_message(code=0x45, payload=b"x" * 300) + decoder = CoapTcpStreamDecoder() + messages = () + + for value in wire: + messages += decoder.feed(bytes((value,))) + + decoder.finish() + assert messages == (parse_coap_tcp_message(wire),) + + +def test_stream_decoder_does_not_apply_one_frame_bound_to_whole_chunk(): + wire = build_coap_tcp_message(code=0x45) + decoder = CoapTcpStreamDecoder(max_message_size=len(wire)) + + messages = decoder.feed(wire * 100) + + assert len(messages) == 100 + assert all(message.code == 0x45 for message in messages) + assert decoder.buffered_bytes == 0 + + +def test_stream_decoder_rejects_oversize_from_prefix_and_resets(): + decoder = CoapTcpStreamDecoder(max_message_size=100) + + with pytest.raises(CoapTcpCodecError, match="declared.*exceeds"): + decoder.feed(bytes.fromhex("f0 00000000")) + + assert decoder.buffered_bytes == 0 + assert decoder.feed(build_coap_tcp_message(code=0x45)) == ( + CoapTcpMessage(code=0x45, token=b"", options=(), payload=b""), + ) + + +def test_stream_decoder_rejects_malformed_frame_and_can_recover(): + decoder = CoapTcpStreamDecoder() + + with pytest.raises(CoapTcpCodecError, match="marker"): + decoder.feed(b"\x10\x45\xff") + + assert decoder.buffered_bytes == 0 + assert len(decoder.feed(build_coap_tcp_message(code=0x45))) == 1 + + +def test_stream_decoder_finish_rejects_and_discards_partial_frame(): + decoder = CoapTcpStreamDecoder() + assert decoder.feed(b"\xe0\x00") == () + assert decoder.buffered_bytes == 2 + + with pytest.raises(CoapTcpCodecError, match="end of stream"): + decoder.finish() + + assert decoder.buffered_bytes == 0 diff --git a/tests/test_import_isolation.py b/tests/test_import_isolation.py index 459c3d9..4e3d90e 100644 --- a/tests/test_import_isolation.py +++ b/tests/test_import_isolation.py @@ -17,6 +17,7 @@ def test_smartthings_local_imports_without_mqtt_demo_present(tmp_path): import_lines = [ "import smartthings_local.protocol.coap", + "import smartthings_local.protocol.coap_tcp", "import smartthings_local.protocol.ocf_multicast", "import smartthings_local.protocol.dtls_session", "import smartthings_local.protocol.ocf_discovery",