Files
MeshCore/test/test_windows_usb_stdio_bridge.py
T

208 lines
10 KiB
Python

"""Binary/identity/ownership tests; no USB hardware, namespaces or listeners."""
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import threading
import time
from types import SimpleNamespace
import unittest
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/hil"))
import windows_usb_stdio_bridge as usb
SERIAL = "AABBCCDDEEFF"
IDENTITY = dict(port="COM6", serial=SERIAL, vid=0x303a, pid=0x1001)
def device_info(serial=SERIAL, port="COM6", vid=0x303a, pid=0x1001):
return SimpleNamespace(device=port, serial_number=serial, vid=vid, pid=pid)
class WindowsBridgeTests(unittest.TestCase):
def test_exact_endpoint_and_required_settings_fail_before_open(self):
self.assertEqual(usb.validate_windows_endpoint("winusb://com6").netloc, "com6")
for value in ("winusb://COM0", "winusb://COM6/", "winusb://COM6:2", "winusb://user@COM6",
"winusb://COM6?baud=1", "winusb://COM6#x", "winusb:///COM6", "tcp://COM6"):
with self.subTest(value=value), self.assertRaises(ValueError):
usb.validate_windows_endpoint(value)
for serial, vid, pid, duration in ((None, 1, 1, 5), ("bad\n", 1, 1, 5), (SERIAL, None, 1, 5),
(SERIAL, 1, 0, 5), (SERIAL, 1, 1, 0), (SERIAL, 1, 1, float("nan")), (SERIAL, 1, 1, 3601)):
with self.assertRaises(ValueError):
usb.validate_settings("COM6", serial, vid, pid, 115200, False, duration)
def test_wrong_missing_ambiguous_usb_identity_never_opens(self):
for inventory in ([], [device_info("OTHER")], [device_info(vid=1)], [device_info(pid=2)],
[device_info(port="COM7")], [device_info(), device_info()]):
factory = mock.Mock()
with self.assertRaises(ValueError):
usb.open_selected_port("COM6", SERIAL, 0x303a, 0x1001, baud=115200, serial_dtr=False,
inventory=lambda: inventory, serial_factory=factory)
factory.assert_not_called()
def test_safe_exclusive_open_checks_twice_and_selects_dtr_without_reset(self):
for dtr in (False, True):
device = mock.Mock()
inventory = mock.Mock(return_value=[device_info()])
with mock.patch.object(usb, "open_configured_session") as opened:
result, identity = usb.open_selected_port("com6", SERIAL, 0x303a, 0x1001,
baud=115200, serial_dtr=dtr, inventory=inventory, serial_factory=lambda: device)
self.assertIs(result, device)
self.assertEqual(identity, dict(IDENTITY, baud=115200, serial_dtr=dtr))
self.assertEqual(inventory.call_count, 2)
opened.assert_called_once_with(device)
self.assertEqual(device.port, "COM6")
self.assertIs(device.exclusive, True)
self.assertIs(device.dtr, dtr)
self.assertIs(device.rts, False)
self.assertEqual((device.timeout, device.write_timeout), (.05, .5))
device.close.assert_not_called()
def test_busy_or_swapped_identity_closes_only_owned_handle(self):
for swap in (False, True):
device = mock.Mock()
inventory = mock.Mock(side_effect=[[device_info()], [device_info("OTHER")]])
with mock.patch.object(usb, "open_configured_session", side_effect=None if swap else OSError("busy")):
with self.assertRaises((ValueError, OSError)):
usb.open_selected_port("COM6", SERIAL, 0x303a, 0x1001, baud=115200, serial_dtr=True,
inventory=inventory, serial_factory=lambda: device)
device.close.assert_called_once()
def test_duplex_short_writes_preserve_every_binary_byte(self):
payload = b"\0\xc0\xdb\xff\r\n" + bytes(range(256))
written, output = bytearray(), bytearray()
output_done = threading.Event()
supplied = [False]
returned = [False]
device = mock.Mock()
device.in_waiting = 0
def host_read(fd, count):
if not supplied[0]:
supplied[0] = True
return payload
output_done.wait(1)
return b""
def serial_read(count):
if not returned[0] and bytes(written) == payload:
returned[0] = True
return payload
time.sleep(.001)
return b""
def serial_write(data):
written.extend(data[:3])
return min(3, len(data))
def host_write(fd, data):
output.extend(data[:2])
if len(output) == len(payload):
output_done.set()
return min(2, len(data))
device.write.side_effect, device.read.side_effect = serial_write, serial_read
stats = usb.pump_stdio(device, 11, 12, duration=2, read=host_read, write=host_write)
self.assertEqual(bytes(written), payload)
self.assertEqual(bytes(output), payload)
self.assertEqual(stats, dict(to_usb=len(payload), from_usb=len(payload), reason="stdin_eof"))
device.close.assert_called_once()
def test_eof_and_deadline_close_usb_even_if_stdout_worker_is_blocked(self):
for eof in (False, True):
blocked, release = threading.Event(), threading.Event()
device = mock.Mock()
device.in_waiting = 4
device.read.return_value = b"\0\xc0\xdb\xff"
def read(fd, count):
blocked.wait(.2)
if not eof:
release.wait(1)
return b""
def write(fd, data):
blocked.set()
release.wait(1)
return len(data)
started = time.monotonic()
try:
stats = usb.pump_stdio(device, 11, 12, duration=.05, read=read, write=write)
self.assertLess(time.monotonic() - started, .6)
device.close.assert_called_once()
self.assertEqual(stats["reason"], "stdin_eof" if eof else "deadline")
finally:
release.set()
def test_parent_uses_absolute_trusted_argv_no_shell_and_distinct_nonblocking_fds(self):
with tempfile.TemporaryDirectory() as directory:
executable = Path(directory) / "python.exe"
executable.touch()
child = mock.Mock()
child.stdout.fileno.return_value, child.stdin.fileno.return_value = 11, 12
identity = dict(IDENTITY, baud=115200, serial_dtr=True)
with mock.patch.object(usb.sys, "platform", "linux"), \
mock.patch.object(usb.subprocess, "run", return_value=SimpleNamespace(stdout="C:\\git\\MeshCore\\tools\\hil\\windows_usb_stdio_bridge.py\n")), \
mock.patch.object(usb.subprocess, "Popen", return_value=child) as popen, \
mock.patch.object(usb, "_ready_record", return_value=dict(identity=identity)), \
mock.patch.object(usb.os, "set_blocking") as nonblocking:
stream = usb.WindowsUsbStream("winusb://COM6", python=str(executable), expected_serial=SERIAL,
expected_vid=0x303a, expected_pid=0x1001, serial_dtr=True, duration=300)
self.assertEqual((stream.read_fd, stream.write_fd), (11, 12))
self.assertEqual(stream.identity, identity)
argv = popen.call_args.args[0]
self.assertEqual(argv[:3], [str(executable), "-u", "C:\\git\\MeshCore\\tools\\hil\\windows_usb_stdio_bridge.py"])
self.assertIn(SERIAL, argv)
self.assertIs(popen.call_args.kwargs["shell"], False)
self.assertEqual(nonblocking.call_args_list, [mock.call(11, False), mock.call(12, False)])
stream.close()
child.stdin.close.assert_called_once()
child.wait.assert_called_once_with(timeout=2)
child.terminate.assert_not_called()
def test_parent_rejects_changed_ready_identity_and_closes_owned_child(self):
with tempfile.TemporaryDirectory() as directory:
executable = Path(directory) / "python.exe"
executable.touch()
child = mock.Mock()
with mock.patch.object(usb.sys, "platform", "linux"), \
mock.patch.object(usb.subprocess, "run", return_value=SimpleNamespace(stdout="C:\\trusted\\windows_usb_stdio_bridge.py")), \
mock.patch.object(usb.subprocess, "Popen", return_value=child), \
mock.patch.object(usb, "_ready_record", return_value=dict(identity=dict(IDENTITY, serial="OTHER"))):
with self.assertRaisesRegex(ValueError, "identity"):
usb.WindowsUsbStream("winusb://COM6", python=str(executable), expected_serial=SERIAL,
expected_vid=0x303a, expected_pid=0x1001)
child.stdin.close.assert_called_once()
child.wait.assert_called_once_with(timeout=2)
def test_parent_close_eof_then_bounded_owned_process_cleanup(self):
stream = usb.WindowsUsbStream.__new__(usb.WindowsUsbStream)
child = mock.Mock()
child.wait.side_effect = [subprocess.TimeoutExpired("owned", 2), subprocess.TimeoutExpired("owned", 1), 0]
stream.process = child
stream.close()
stream.close()
child.stdin.close.assert_called_once()
child.terminate.assert_called_once()
child.kill.assert_called_once()
self.assertEqual(child.wait.call_args_list, [mock.call(timeout=2), mock.call(timeout=1), mock.call(timeout=1)])
def test_ready_status_bound_and_invalid_records_do_not_expose_child_stderr(self):
for data in (b"secret payload, not JSON\n", b'{"state":"error","error":"private"}\n', b"x" * 4097):
child = mock.Mock()
child.stderr.fileno.return_value = 11
with mock.patch.object(usb.select, "select", return_value=([11], [], [])), \
mock.patch.object(usb.os, "read", return_value=data):
with self.assertRaises(RuntimeError) as failure:
usb._ready_record(child)
self.assertNotIn("secret", str(failure.exception))
self.assertNotIn("private", str(failure.exception))
if __name__ == "__main__":
unittest.main()