208 lines
10 KiB
Python
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()
|