"""TAP relay byte/backpressure checks without privileged resources or sockets.""" from pathlib import Path import sys from types import SimpleNamespace import unittest from unittest.mock import Mock, patch sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/hil")) import ethernet_tap_relay as relay from ethernet_kiss_probe import kiss_encode, KissDecoder class EthernetTapRelayTests(unittest.TestCase): def test_windows_kiss_transport_keeps_raw_bytes_and_separate_fds(self): shared = Mock(read_fd=77, write_fd=78) payload = b"\xc0\x10\0\xdb\xdc\xff\xc0" shared.read.return_value = payload shared.write.return_value = len(payload) with patch.object(relay, "WindowsUsbStream", return_value=shared) as opened: stream = relay.KissStream("winusb://COM6", 115200, windows_python="/mnt/c/python.exe", expect_serial="EXACT", expect_vid=0x303a, expect_pid=0x1001, serial_dtr=True, duration=300) self.assertEqual((stream.read_fd, stream.write_fd), (77, 78)) self.assertEqual(stream.read(99), payload) self.assertEqual(stream.write(payload), len(payload)) stream.close() opened.assert_called_once_with("winusb://COM6", python="/mnt/c/python.exe", expected_serial="EXACT", expected_vid=0x303a, expected_pid=0x1001, baud=115200, serial_dtr=True, duration=300) shared.write.assert_called_once_with(payload) shared.close.assert_called_once() def test_kiss_relay_uses_separate_pipe_fds_without_changing_modem_pacing(self): stream = Mock(read_fd=77, write_fd=78) stream.write.side_effect = len selections = [] def selected(reads, writes, errors, timeout): selections.append((reads, writes)) return [], [78], [] with patch.object(relay.select, "select", side_effect=selected): stats = relay.relay(stream, 88, duration=1, kiss_modem=True, kiss_port=1, stop=lambda: len(selections) >= 3) self.assertEqual(selections, [([88, 77], [78])] * 3) self.assertEqual(stats["modem_controls_written"], 3) self.assertEqual(stats["kiss_written"], 0) def test_selected_port_preserves_bytes_and_ignores_other_data_and_full_modem_metadata(self): frame = bytes(range(255)) metadata = b"\xc0\x06\xf9\x00\x01\xc0\x06\xf8\x01\xc0" for port in (0, 1): buffers = relay.RelayBuffers(kiss_port=port) buffers.queue_tap_frame(frame) self.assertEqual(buffers.to_kiss[0][1], port << 4) self.assertEqual(KissDecoder(port=port).feed(buffers.to_kiss[0]), [frame]) buffers.feed_kiss(metadata + kiss_encode(b"other" * 12, port=1 - port) + kiss_encode(frame, port=port)) self.assertEqual(list(buffers.to_tap), [frame]) self.assertEqual(buffers.stats()["decode_errors"], 0) for invalid in (True, -1, 2, 1.0): with self.assertRaises(ValueError): relay.RelayBuffers(kiss_port=invalid) with self.assertRaises(ValueError): kiss_encode(frame, port=invalid) def test_bench_endpoint_allowlist_is_explicit_and_does_not_access_devices(self): for value in ("serial:///dev/serial/by-id/usb-RAK_4631_ABC-if00", "serial:///dev/serial/by-path/platform-usb-0:1.2:1.0", "serial:///dev/ttyACM0", "serial:///dev/ttyUSB0", "tcp://192.168.1.84:5003"): self.assertIn(relay.validate_endpoint(value, strict_tcp=True).scheme, ("serial", "tcp")) for value in ("serial:///dev/mem", "serial:///etc/passwd", "serial://host/dev/ttyACM0", "serial:///dev/serial/by-id/../by-path/device", "serial:///dev/ttyACM0?baud=1", "serial:///dev/serial/by-id/%2fetc", "tcp://radio:5003", "tcp://192.168.1.84:5002", "tcp://user:secret@192.168.1.84:5003", "tcp://192.168.1.84:5003/path"): with self.assertRaises(ValueError): relay.validate_endpoint(value, strict_tcp=True) with patch.object(relay.os.path, "realpath", return_value="/dev/mem"), \ patch.object(relay, "open_configured_session") as opened: with self.assertRaises(ValueError): relay.KissStream("serial:///dev/serial/by-id/explicit-device", 115200) opened.assert_not_called() def test_explicit_full_modem_initialization_then_one_data_until_successful_tx_done(self): clock = [100.0] buffers = relay.RelayBuffers(kiss_port=1, kiss_modem=True, now=lambda: clock[0]) frames = [b"a" * 60, b"b" * 60] for frame in frames: buffers.queue_tap_frame(frame) written = [] while buffers.controls: buffers.write_kiss(lambda data: written.append(data) or len(data)) self.assertEqual(written, [b"\xc0\x01\0\xc0", b"\xc0\x02\xff\xc0", b"\xc0\x03\x01\xc0"]) buffers.write_kiss(lambda data: written.append(data) or len(data)) self.assertEqual(written[-1], kiss_encode(frames[0], port=1)) self.assertFalse(buffers.can_write_kiss()) writer = Mock(); buffers.write_kiss(writer); writer.assert_not_called() buffers.feed_kiss(b"\xc0\x06\xf9\x00\x01\xc0") self.assertEqual(buffers.stats()["modem_rx_meta"], 1) self.assertFalse(buffers.can_write_kiss()) buffers.feed_kiss(b"\xc0\x16\xf8\x01\xc0") # Wrong control port cannot release it. self.assertFalse(buffers.can_write_kiss()) buffers.feed_kiss(b"\xc0\x06\xf8\x01\xc0") self.assertTrue(buffers.can_write_kiss()) self.assertEqual(buffers.stats()["modem_tx_done"], 1) buffers.write_kiss(lambda data: written.append(data) or len(data)) self.assertEqual(written[-1], kiss_encode(frames[1], port=1)) clock[0] += 10 with self.assertRaises(relay.ModemFlowError) as failure: buffers.check_tx_done() self.assertEqual(failure.exception.stats["modem_tx_timeout"], 1) def test_full_modem_errors_fail_closed_no_next_data_or_automatic_retry(self): for control in (b"\xc0\x06\xf8\x02\xc0", b"\xc0\x06\xf1\x07\xc0", b"\xc0\x06\xf8\xc0"): buffers = relay.RelayBuffers(kiss_modem=True) buffers.queue_tap_frame(b"a" * 60); buffers.queue_tap_frame(b"b" * 60) while buffers.controls: buffers.write_kiss(len) buffers.write_kiss(len) with self.assertRaises(relay.ModemFlowError) as failure: buffers.feed_kiss(control) self.assertEqual(failure.exception.stats["modem_tx_error"], 1) self.assertEqual(len(buffers.to_kiss), 1) self.assertFalse(buffers.can_write_kiss()) def test_rejected_tx_done_drops_one_frame_then_allows_next_without_replay(self): buffers = relay.RelayBuffers(kiss_port=1, kiss_modem=True) frames = [b"first" * 12, b"other" * 12] for frame in frames: buffers.queue_tap_frame(frame) written = [] while buffers.controls: buffers.write_kiss(len) buffers.write_kiss(lambda data: written.append(data) or len(data)) self.assertFalse(buffers.can_write_kiss()) buffers.feed_kiss(b"\xc0\x06\xf8\x00\xc0") self.assertTrue(buffers.can_write_kiss()) self.assertEqual(buffers.stats()["modem_tx_error"], 1) self.assertEqual(buffers.stats()["modem_tx_done"], 0) buffers.write_kiss(lambda data: written.append(data) or len(data)) self.assertEqual(written, [kiss_encode(frame, port=1) for frame in frames]) self.assertEqual(list(buffers.to_kiss), []) self.assertEqual(buffers.stats()["kiss_written"], 2) self.assertTrue(buffers.stats()["awaiting_tx_done"]) buffers.feed_kiss(b"\xc0\x06\xf8\x01\xc0") self.assertEqual(buffers.stats()["modem_tx_done"], 1) self.assertFalse(buffers.stats()["awaiting_tx_done"]) def test_unsolicited_tx_done_does_not_create_a_credit_or_change_default_tcp_flow(self): buffers = relay.RelayBuffers(kiss_modem=True) buffers.feed_kiss(b"\xc0\x06\xf8\x01\xc0") self.assertEqual(buffers.stats()["modem_unexpected_tx_done"], 1) default = relay.RelayBuffers() self.assertFalse(default.controls) default.queue_tap_frame(b"a" * 60); default.queue_tap_frame(b"b" * 60) default.write_kiss(len); default.write_kiss(len) self.assertEqual(default.stats()["kiss_written"], 2) self.assertFalse(default.stats()["awaiting_tx_done"]) def test_phone_fixture_keeps_default_tcp_and_explicitly_forwards_serial_modem_options(self): source = (Path(relay.__file__).parent / "ethernet_ssh_phone_bench.sh").read_text() self.assertIn("kiss_port=0; baud=115200; serial_dtr=off; kiss_modem=off", source) self.assertIn('validate_endpoint(sys.argv[1], strict_tcp=True)', source) self.assertIn('--baud "$baud" --kiss-port "$kiss_port" --serial-dtr "$serial_dtr"', source) self.assertIn('modem_args+=(--kiss-modem)', source) self.assertIn('Server helper PID: %s', source) self.assertIn('"$observed" == "$inode"', source) def test_tcp_transport_defaults_to_dedicated_kiss_port(self): with patch.object(relay.socket, "create_connection") as connect: endpoint = relay.KissStream("tcp://bench-radio", 115200) connect.assert_called_once_with(("bench-radio", 5003), timeout=5) endpoint.close() def test_current_host_namespace_is_rejected_before_setns(self): identity = SimpleNamespace(st_dev=5, st_ino=700) with patch.object(relay.os, "open", return_value=88), \ patch.object(relay.os, "O_CLOEXEC", 0, create=True), \ patch.object(relay.os, "fstat", return_value=identity), \ patch.object(relay.os, "stat", return_value=identity), \ patch.object(relay.os, "close") as close, \ patch.object(relay.os, "setns", create=True) as setns: with self.assertRaisesRegex(ValueError, "current host"): relay.enter_network_namespace("/proc/self/ns/net") setns.assert_not_called() close.assert_called_once_with(88) def test_distinct_namespace_is_entered_and_handle_closed(self): with patch.object(relay.os, "open", return_value=88), \ patch.object(relay.os, "O_CLOEXEC", 0, create=True), \ patch.object(relay.os, "fstat", return_value=SimpleNamespace(st_dev=5, st_ino=701)), \ patch.object(relay.os, "stat", return_value=SimpleNamespace(st_dev=5, st_ino=700)), \ patch.object(relay.os, "close") as close, \ patch.object(relay.os, "setns", create=True) as setns: relay.enter_network_namespace("/var/run/netns/bench-a") setns.assert_called_once_with(88, 0x40000000) close.assert_called_once_with(88) def test_kiss_is_opened_before_namespace_or_tap(self): calls = [] stream = Mock() with patch.object(relay, "KissStream", side_effect=lambda *args, **kwargs: calls.append("socket") or stream), \ patch.object(relay, "enter_network_namespace", side_effect=lambda path: calls.append("netns")), \ patch.object(relay, "create_tap", side_effect=lambda name: calls.append("tap") or (77, name)): self.assertEqual(relay.open_transport_then_tap("tcp://host:5003", 115200, "/ns", "lora0"), (stream, 77, "lora0")) self.assertEqual(calls, ["socket", "netns", "tap"]) stream.close.assert_not_called() def test_failed_namespace_or_tap_closes_kiss(self): for stage in ("enter_network_namespace", "create_tap"): stream = Mock() with patch.object(relay, "KissStream", return_value=stream), \ patch.object(relay, "enter_network_namespace"), \ patch.object(relay, "create_tap", return_value=(77, "lora0")), \ patch.object(relay, stage, side_effect=OSError("denied")): with self.assertRaises(OSError): relay.open_transport_then_tap("tcp://host:5003", 115200, "/ns", "lora0") stream.close.assert_called_once() def test_partial_stream_writes_preserve_exact_frame(self): buffers = relay.RelayBuffers(1) frame = bytes(range(255)) self.assertTrue(buffers.queue_tap_frame(frame)) self.assertFalse(buffers.can_read_tap()) with self.assertRaises(BufferError): buffers.queue_tap_frame(frame) written = bytearray() def write(data): written.extend(data[:3]) return min(3, len(data)) buffers.write_kiss(lambda data: 0) while buffers.to_kiss: buffers.write_kiss(write) self.assertTrue(buffers.can_read_tap()) self.assertEqual(bytes(written), kiss_encode(frame)) self.assertEqual(KissDecoder().feed(bytes(written)), [frame]) self.assertEqual(buffers.stats()["kiss_written"], 1) def test_incoming_staging_applies_backpressure_without_losing_frames(self): buffers = relay.RelayBuffers(1) frames = [bytes((index,)) * 255 for index in range(3)] buffers.feed_kiss(b"".join(kiss_encode(frame) for frame in frames)) self.assertEqual(len(buffers.to_tap), 1) self.assertTrue(buffers.staging) self.assertFalse(buffers.can_read_kiss()) with self.assertRaises(BufferError): buffers.feed_kiss(b"more bytes") written = [] for frame in frames: buffers.write_tap(lambda data: written.append(data) or len(data)) buffers.parse_pending() self.assertEqual(written, frames) self.assertTrue(buffers.can_read_kiss()) self.assertEqual(buffers.stats()["kiss_read"], 3) self.assertEqual(buffers.stats()["tap_written"], 3) def test_partial_tap_write_is_not_retried_as_a_new_frame(self): buffers = relay.RelayBuffers() buffers.feed_kiss(kiss_encode(b"x" * 60)) with self.assertRaisesRegex(IOError, "partial TAP"): buffers.write_tap(lambda frame: 20) self.assertEqual(buffers.stats()["tap_written"], 0) self.assertEqual(len(buffers.to_tap), 1) def test_invalid_frame_sizes_are_dropped_not_truncated(self): buffers = relay.RelayBuffers() for size in (0, 13, 256, 1500): self.assertFalse(buffers.queue_tap_frame(b"x" * size)) self.assertEqual(len(buffers.to_kiss), 0) buffers.feed_kiss(kiss_encode(b"x" * 13)) self.assertEqual(len(buffers.to_tap), 0) self.assertEqual(buffers.stats()["bad_frame_length"], 5) for size in (14, 255): self.assertTrue(buffers.queue_tap_frame(b"x" * size)) def test_bad_stream_cannot_grow_unbounded(self): buffers = relay.RelayBuffers() with self.assertRaises(BufferError): buffers.feed_kiss(b"x" * 4097) buffers.feed_kiss(b"\xc0\0" + b"x" * 3500) self.assertEqual(buffers.staging, b"") self.assertLessEqual(len(buffers.decoder.buffer), 257) self.assertEqual(buffers.stats()["decode_errors"], 1) if __name__ == "__main__": unittest.main()