diff --git a/boot/initramfs/root/init b/boot/initramfs/root/init index 9556f126..14e6083d 100755 --- a/boot/initramfs/root/init +++ b/boot/initramfs/root/init @@ -97,13 +97,17 @@ usb_gadget() { [ ! -e "$g" ] || return 0 name=$(sed -n 's/^NAME=//p' /newroot/etc/os-release /newroot/usr/lib/os-release 2>/dev/null | head -1 | tr -d "\"'") board=$(tr '\000' '\n' < /proc/device-tree/compatible | head -1) + # One serial for every boot of this unit (docs/device-protocol.md), or + # the per-boot one if the helper fails: USB must always come up. + serial=$(sh /etc/orchard/usb-identity 2>/dev/null) || + serial="BOOT:$(cat /proc/sys/kernel/random/boot_id) BOARD:${board#apple,}" mkdir "$g" && echo 0x05ac > "$g/idVendor" && echo 0x4145 > "$g/idProduct" && mkdir -p "$g/strings/0x409" "$g/configs/c.1/strings/0x409" && echo 'Orchard Linux' > "$g/strings/0x409/manufacturer" && echo "${name:-Linux}" > "$g/strings/0x409/product" && - echo "BOOT:$(cat /proc/sys/kernel/random/boot_id) BOARD:${board#apple,}" > "$g/strings/0x409/serialnumber" && + echo "$serial" > "$g/strings/0x409/serialnumber" && echo 'USB network and serial' > "$g/configs/c.1/strings/0x409/configuration" && echo 500 > "$g/configs/c.1/MaxPower" && mkdir "$g/functions/ncm.usb0" "$g/functions/acm.usb0" && diff --git a/docs/device-protocol.md b/docs/device-protocol.md index f15bfac0..518087d8 100644 --- a/docs/device-protocol.md +++ b/docs/device-protocol.md @@ -15,6 +15,24 @@ the host and device side of an install always come from the same CLI version. - Private bundle: exactly the device firmware data plus `mixin.json`; the install script writes it to the installed root's `/usr/lib/firmware`. +## USB identity + +Installed-system and standard RAM gadgets use `BOARD: ID:` as +their USB serial number. The ID is the first 12 hex characters of SHA-256 over +`orchard-` followed by the device-tree `serial-number` without NUL +padding. It identifies the unit across boots without publishing the hardware +serial. No descriptor changes per boot, so macOS can retain accessory approval. +Trees without a serial number warn and retain the legacy `BOOT:` identity to +keep rescue USB available; those still need approval each boot. + +For RAM sessions, ACM returns `/proc/sys/kernel/random/boot_id` and the SSH +host public key together on every connection. The host validates the boot ID +and pins that boot's key. Installed systems use their persistent SSH keys and +do not allocate per-boot host state. Recovery and install keep their existing +`ECID:`/`BOOT:` serials and bootstrap/status checks. The install-to-system +handoff also retains `BOOT:`: the CLI's install add-on can run on older initrds +without the stable-identity helper. Normal installed boots use the new format. + ## Operations `orchard-device OPERATION [BASE64_JSON]`; requests may include `protocol: 1`. diff --git a/pyproject.toml b/pyproject.toml index f48aa158..ad17df07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ package-dir = {"" = "src"} where = ["src"] [tool.setuptools.package-data] -orchard = ["py.typed", "usb-session"] +orchard = ["py.typed", "usb-session", "usb-identity"] [dependency-groups] dev = ["mypy==1.18.2", "jsonschema>=4.25,<5"] diff --git a/src/orchard/apple/linux_usb.py b/src/orchard/apple/linux_usb.py index 6095440c..ee3ccd52 100644 --- a/src/orchard/apple/linux_usb.py +++ b/src/orchard/apple/linux_usb.py @@ -30,6 +30,12 @@ def serial_tags(serial): 'pwned': pwned.group(1) if pwned else ('checkra1n' if 'YOLO:checkra1n' in serial else None)} +def stable_id(serial): + """Hashed per-device identity, absent on legacy gadgets.""" + match = re.search(r'\bID:([0-9a-fA-F]{12})\b', serial) + return match.group(1).lower() if match else None + + def devices(root=Path('/sys/bus/usb/devices')): found = [] for node in root.iterdir(): @@ -45,6 +51,7 @@ def devices(root=Path('/sys/bus/usb/devices')): ecid = re.search(r'\bECID:([0-9a-fA-F]+)\b', serial) chip = re.search(r'\bCPID:([0-9a-fA-F]+)\b', serial) board = re.search(r'\bBDID:([0-9a-fA-F]+)\b', serial) + usb_id = stable_id(serial) profile = re.search(r'\bBOARD:([a-z0-9]+)\b', serial) ports = {str(Path('/dev') / p.name) for p in node.glob('**/tty/ttyACM*')} if len(ports) > 1: @@ -53,6 +60,7 @@ def devices(root=Path('/sys/bus/usb/devices')): found.append(dict(location=node.name, mode=MODES.get(pid, 'other'), ecid=int(ecid.group(1), 16) if ecid else None, boot_id=str(uuid.UUID(boot.group(1))) if boot else None, + usb_id=usb_id, chip=int(chip.group(1), 16) if chip else None, board=int(board.group(1), 16) if board else None, profile=profile.group(1) if profile else None, product=product, diff --git a/src/orchard/apple/usb.py b/src/orchard/apple/usb.py index 1b1a4bf3..c53aa021 100644 --- a/src/orchard/apple/usb.py +++ b/src/orchard/apple/usb.py @@ -5,7 +5,7 @@ import sys import uuid -from .linux_usb import MODES, M1N1, devices as linux_devices, m1n1_ports as linux_m1n1_ports, serial_tags +from .linux_usb import MODES, M1N1, devices as linux_devices, m1n1_ports as linux_m1n1_ports, serial_tags, stable_id from orchard.state import EndpointUnavailable @@ -33,6 +33,7 @@ def devices(): continue serial = node.get('USB Serial Number') or '' boot = re.search(r'\bBOOT:([0-9a-fA-F-]{36})\b', serial) + usb_id = stable_id(serial) profile = re.search(r'\bBOARD:([a-z0-9]+)\b', serial) fields = [re.search(r'\b' + label + r':([0-9a-fA-F]+)\b', serial) for label in ('ECID', 'CPID', 'BDID')] @@ -50,6 +51,7 @@ def devices(): found.append({'location': location, 'mode': MODES.get(node.get('idProduct'), 'other'), 'ecid': int(fields[0].group(1), 16) if fields[0] else None, 'boot_id': str(uuid.UUID(boot.group(1))) if boot else None, + 'usb_id': usb_id, 'chip': int(fields[1].group(1), 16) if fields[1] else None, 'board': int(fields[2].group(1), 16) if fields[2] else None, 'profile': profile.group(1) if profile else None, diff --git a/src/orchard/image/platform.py b/src/orchard/image/platform.py index b39ae7bb..ee55ae55 100644 --- a/src/orchard/image/platform.py +++ b/src/orchard/image/platform.py @@ -111,6 +111,7 @@ def initrd(modules, release, busybox, *, hardware=None, storage_modules=STORAGE_ entries = {name: (directory, b'') for name in ('bin', 'dev', 'proc', 'sys', 'etc', 'etc/orchard', 'newroot', 'lib', 'lib/modules', 'lib/modules/' + release)} entries['init'] = (stat.S_IFREG | 0o755, (hardware / 'boot/initramfs/root/init').read_bytes()) + entries['etc/orchard/usb-identity'] = (stat.S_IFREG | 0o644, (hardware / 'src/orchard/usb-identity').read_bytes()) entries['bin/busybox'] = (stat.S_IFREG | 0o755, Path(busybox).read_bytes()) entries['etc/orchard/modules'] = (stat.S_IFREG | 0o644, ('\n'.join(loaded) + '\n').encode()) if usb: diff --git a/src/orchard/ram_session.py b/src/orchard/ram_session.py index 1d1439ba..26c58889 100644 --- a/src/orchard/ram_session.py +++ b/src/orchard/ram_session.py @@ -18,6 +18,7 @@ import subprocess import sys import time +import uuid from .serial import SerialError, SerialPort from .state import cache_root, private_directory @@ -49,7 +50,9 @@ def identity(): def payload_files(): """RAM-root files that enable the USB session in a payload.""" helper = resources.files('orchard') / 'usb-session' - return {HELPER: Path(str(helper)), AUTHORIZED: identity().with_suffix('.pub')} + return {HELPER: Path(str(helper)), + 'usr/libexec/orchard/usb-identity': Path(str(resources.files('orchard') / 'usb-identity')), + AUTHORIZED: identity().with_suffix('.pub')} def target(): @@ -69,17 +72,20 @@ def target(): def _check(device): + # Stable descriptors identify the unit, not its boot. RAM SSH is pinned to + # the per-boot key read on the open ACM endpoint; a reboot invalidates it. + fields = ('location', 'mode', 'profile', 'usb_id', 'boot_id', 'serial_port') def check(): from .apple import usb - if not any(item['location'] == device['location'] and item.get('boot_id') == device.get('boot_id') + if not any(all(item.get(key) == device.get(key) for key in fields) for item in usb.devices()): raise ValueError('The RAM payload disconnected or rebooted') return check def connect(device=None, user=None, *, host=None, ram=False): - """SSH argv for the running payload (its host key pinned over serial once - per boot) or installed system (as its user, with this computer's key).""" + """SSH argv for the running payload (its boot and host key read over ACM) + or installed system (as its user, with this computer's key).""" if ram and (not host or user): raise ValueError('--ram requires --host and connects as root, not --user') if host: @@ -106,20 +112,33 @@ def connect(device=None, user=None, *, host=None, ram=False): argv = computer_key.ssh_argv(user) wait_authenticated(argv, _check(device), seconds=20) return argv - boot = device.get('boot_id') - if not boot: + if not (device.get('boot_id') or device.get('usb_id')): raise ValueError('The RAM payload has no boot identity; rebuild it with a current orchard') check = _check(device) + # Query every connection: the same stable USB serial can name a new boot. + # ACM supplies both boot ID and trusted host key without changing any USB + # descriptor, and also works with payloads built before stable identities. + with SerialPort(device['serial_port'], check) as serial: + code, text = serial.run('cat /proc/sys/kernel/random/boot_id && ' + 'cat /run/orchard/ssh_host_ed25519_key.pub', timeout=15) + if code: + raise ValueError('No SSH in this payload; its /init must run ' + '/' + HELPER + ' (or build without transport = false)') + lines = text.strip().splitlines() + try: + boot = str(uuid.UUID(lines[0].strip())) + except (ValueError, IndexError) as error: + raise ValueError('Invalid RAM boot identity over USB serial') from error + if device.get('boot_id') and boot != device['boot_id']: + raise ValueError('The RAM payload disconnected or rebooted') + pinned = HOST + ' ' + host_key('\n'.join(lines[1:])) + '\n' hosts = _directory() / f'known_hosts-{boot}' + if hosts.is_file() and hosts.read_text() != pinned: + raise ValueError('The RAM SSH host key changed within the same boot') if not hosts.is_file(): - with SerialPort(device['serial_port'], check) as serial: - code, text = serial.run('cat /run/orchard/ssh_host_ed25519_key.pub', timeout=15) - if code: - raise ValueError('No SSH in this payload; its /init must run ' - '/' + HELPER + ' (or build without transport = false)') for old in _directory().glob('known_hosts-*'): old.unlink() - hosts.write_text(HOST + ' ' + host_key(text) + '\n') + hosts.write_text(pinned) hosts.chmod(0o600) argv = ssh_argv(identity(), hosts, HOST, 22) wait_authenticated(argv, check, seconds=20) diff --git a/src/orchard/usb-identity b/src/orchard/usb-identity new file mode 100644 index 00000000..2538ee03 --- /dev/null +++ b/src/orchard/usb-identity @@ -0,0 +1,24 @@ +#!/bin/sh +# Stable USB identity, shared by installed-system and RAM gadgets. The boot +# stage copies serial-number from Apple's tree; never publish it in the clear. +set -eu +tree=${ORCHARD_DEVICE_TREE:-/proc/device-tree} +board=$(tr '\000' '\n' < "$tree/compatible" | head -1) +serial= +if [ -r "$tree/serial-number" ]; then + serial=$(tr -d '\000' < "$tree/serial-number") +fi +id= +if [ -n "$serial" ]; then + id=$(printf 'orchard-%s' "$serial" | sha256sum 2>/dev/null | cut -c1-12) || id= + case "$id" in *[!0-9a-f]* | '') id= ;; esac + [ ${#id} -eq 12 ] || id= +fi +if [ -n "$id" ]; then + printf 'BOARD:%s ID:%s\n' "${board#apple,}" "$id" +else + # Keep rescue serial and SSH reachable on trees without a hardware ID. + # This legacy fallback cannot retain the host's accessory approval. + echo 'orchard: no device-tree serial-number; using per-boot USB identity' >&2 + printf 'BOOT:%s BOARD:%s\n' "$(cat /proc/sys/kernel/random/boot_id)" "${board#apple,}" +fi diff --git a/src/orchard/usb-session b/src/orchard/usb-session index aadde6a1..84c7a932 100755 --- a/src/orchard/usb-session +++ b/src/orchard/usb-session @@ -17,7 +17,8 @@ echo 0x05ac > "$g/idVendor" echo 0x4144 > "$g/idProduct" mkdir -p "$g/strings/0x409" "$g/configs/c.1/strings/0x409" echo "Orchard $name" > "$g/strings/0x409/product" -echo "BOOT:$(cat /proc/sys/kernel/random/boot_id)" > "$g/strings/0x409/serialnumber" +sh /usr/libexec/orchard/usb-identity > "$g/strings/0x409/serialnumber" 2>/dev/null || + echo "BOOT:$(cat /proc/sys/kernel/random/boot_id)" > "$g/strings/0x409/serialnumber" echo 'USB network and serial' > "$g/configs/c.1/strings/0x409/configuration" # Descriptor only: not charging capability or PMIC policy. echo 100 > "$g/configs/c.1/MaxPower" diff --git a/tests/test_identity.py b/tests/test_identity.py index 514c2d9e..5a9524cc 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py @@ -1,9 +1,12 @@ """Boot identity publication from bounded ADT fixtures; no device activation.""" import importlib.util +import hashlib import json import os from pathlib import Path +import re import stat +import subprocess import struct import sys import tempfile @@ -107,5 +110,75 @@ def test_gadget_publishes_identity_before_binding_usb(self): self.assertLess(source.index('/serialnumber'),source.index('"$g/UDC"')) +class GadgetIdentity(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.tree = self.root / 'proc/device-tree' + self.tree.mkdir(parents=True) + (self.tree / 'compatible').write_bytes(b'apple,j71t\0apple,s8003\0') + boot = self.root / 'proc/sys/kernel/random/boot_id' + boot.parent.mkdir(parents=True) + boot.write_text(BOOT) + self.script = (ROOT / 'src/orchard/usb-identity').read_text().replace( + '/proc/sys/kernel/random/boot_id', str(boot)) + + def serial(self): + return subprocess.run(['sh', '-ec', self.script], check=True, capture_output=True, text=True, + env={**os.environ, 'ORCHARD_DEVICE_TREE': str(self.tree)}) + + def test_hardware_serial_is_hashed_stable_across_boots_and_distinct_per_unit(self): + number = self.tree / 'serial-number' + number.write_bytes(b'HardwareSerialA\0') + first = self.serial() + expected = hashlib.sha256(b'orchard-HardwareSerialA').hexdigest()[:12] + self.assertEqual(first.stdout.strip(), f'BOARD:j71t ID:{expected}') + self.assertNotIn('HardwareSerialA', first.stdout + first.stderr) + (self.root / 'proc/sys/kernel/random/boot_id').write_text('22222222-2222-4222-8222-222222222222') + self.assertEqual(self.serial().stdout, first.stdout) + number.write_bytes(b'HardwareSerialB\0') + self.assertNotEqual(self.serial().stdout, first.stdout) + + def test_missing_or_empty_serial_preserves_rescue_usb_with_a_warning(self): + for value in (None, b'', b'\0'): + if value is not None: + (self.tree / 'serial-number').write_bytes(value) + result = self.serial() + self.assertEqual(result.stdout.strip(), f'BOOT:{BOOT} BOARD:j71t') + self.assertIn('no device-tree serial-number', result.stderr) + + def test_installed_and_ram_gadgets_use_the_same_stable_serial_before_binding(self): + (self.tree / 'serial-number').write_bytes(b'HardwareSerialA\0') + expected = self.serial().stdout.strip() + for relative in ('boot/initramfs/root/init', 'src/orchard/usb-session'): + with self.subTest(path=relative): + source = (ROOT / relative).read_text() + if relative.endswith('/init'): + start = source.index('usb_gadget() {') + source = source[start:source.index('\nusb_gadget ||', start)] + '\nusb_gadget' + else: + source = source[source.index('name=$(cat'):source.index('\nifconfig ')] + for directory in ('sys/kernel/config/usb_gadget', 'sys/class/udc/usbdev', + 'newroot/etc', 'newroot/usr/lib', 'etc', 'usr/libexec/orchard'): + (self.root / directory).mkdir(parents=True, exist_ok=True) + (self.root / 'newroot/etc/os-release').write_text('NAME="Linux"\n') + (self.root / 'etc/hostname').write_text('ram-test\n') + for helper in ('etc/orchard/usb-identity', 'usr/libexec/orchard/usb-identity'): + path = self.root / helper + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(self.script) + source = re.sub(r'/(sys|newroot|etc|usr)/', lambda m: str(self.root) + m.group(), source) + source = source.replace('mkdir "$g"', 'mkdir -p "$g/functions"') + subprocess.run(['sh', '-ec', 'mount() { :; };\n' + source], check=True, capture_output=True, + env={**os.environ, 'ORCHARD_DEVICE_TREE': str(self.tree)}) + name = 'orchard-system' if relative.endswith('/init') else 'ram-test' + gadget = self.root / 'sys/kernel/config/usb_gadget' / name + self.assertEqual((gadget / 'strings/0x409/serialnumber').read_text().strip(), expected) + self.assertEqual((gadget / 'UDC').read_text().strip(), 'usbdev') + for function in ('ncm.usb0', 'acm.usb0'): + self.assertTrue((gadget / 'configs/c.1' / function).is_symlink()) + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_platform_layer.py b/tests/test_platform_layer.py index 9acba731..013e405a 100644 --- a/tests/test_platform_layer.py +++ b/tests/test_platform_layer.py @@ -82,6 +82,7 @@ def test_the_initrd_carries_busybox_and_the_storage_modules_only(self): base = f'lib/modules/{RELEASE}/' self.assertEqual(names['bin/busybox'], b'static busybox') self.assertEqual(names['init'], (ROOT / 'boot/initramfs/root/init').read_bytes()) + self.assertEqual(names['etc/orchard/usb-identity'], (ROOT / 'src/orchard/usb-identity').read_bytes()) self.assertEqual(names['etc/orchard/modules'], b'nvme\n') self.assertEqual(sorted(name for name in names if name.endswith('.ko')), [base + 'kernel/drivers/nvme/host/nvme-core.ko', base + 'kernel/drivers/nvme/host/nvme.ko']) diff --git a/tests/test_ram.py b/tests/test_ram.py index 89c83aaa..6e430d29 100644 --- a/tests/test_ram.py +++ b/tests/test_ram.py @@ -475,6 +475,7 @@ def test_usb_session_is_added_unless_the_project_opts_out(self): ram.build_directory(example, hardware=self.hardware) archive = gzip.decompress((example / 'out/apple-16k/initramfs.cpio.gz').read_bytes()) self.assertIn(b'usr/libexec/orchard/usb-session\0', archive) + self.assertIn(b'usr/libexec/orchard/usb-identity\0', archive) self.assertIn(b'etc/orchard/authorized_keys\0', archive) public = (self.base / 'home/private/ram-ssh/id_ed25519.pub').read_bytes() self.assertIn(public, archive) @@ -484,6 +485,7 @@ def test_usb_session_is_added_unless_the_project_opts_out(self): ram.build_directory(example, hardware=self.hardware) archive = gzip.decompress((example / 'out/apple-16k/initramfs.cpio.gz').read_bytes()) self.assertNotIn(b'usr/libexec/orchard/usb-session\0', archive) + self.assertNotIn(b'usr/libexec/orchard/usb-identity\0', archive) self.assertNotIn(b'etc/orchard/authorized_keys\0', archive) def test_arbitrary_directory_layout_uses_active_hardware_package(self): diff --git a/tests/test_system_session.py b/tests/test_system_session.py index 197c0907..8f89b1d6 100644 --- a/tests/test_system_session.py +++ b/tests/test_system_session.py @@ -9,6 +9,9 @@ SYSTEM = {'location': 1, 'mode': 'system', 'product': 'Omarchy A', 'serial_port': '/dev/cu.x', 'boot_id': '00000000-0000-4000-8000-000000000001', 'profile': 'j71b'} PAYLOAD = {**SYSTEM, 'mode': 'ram', 'product': 'Orchard gpu_001'} +STABLE_PAYLOAD = {**PAYLOAD, 'boot_id': None, 'usb_id': 'f09dfe4c604a'} +BOOT_A = '11111111-1111-4111-8111-111111111111' +BOOT_B = '22222222-2222-4222-8222-222222222222' class SystemSession(unittest.TestCase): @@ -62,6 +65,60 @@ def test_without_a_user_it_says_how_to_get_one(self): with self.assertRaisesRegex(ValueError, '--user'): ram_session.connect(SYSTEM) + def ram_connect(self, reply, device=STABLE_PAYLOAD): + with mock.patch.object(ram_session, '_directory', return_value=Path(self.temp.name)), \ + mock.patch.object(ram_session, 'identity', return_value=Path('/ram-key')), \ + mock.patch.object(ram_session, 'SerialPort') as port, \ + mock.patch.object(ram_session, 'wait_authenticated') as wait: + port.return_value.__enter__.return_value.run.return_value = (0, reply) + argv = ram_session.connect(device) + command = port.return_value.__enter__.return_value.run.call_args.args[0] + self.assertIn('/proc/sys/kernel/random/boot_id', command) + self.assertIn('/run/orchard/ssh_host_ed25519_key.pub', command) + self.assertIn('StrictHostKeyChecking=yes', argv) + wait.assert_called_once() + return argv + + def test_stable_usb_identity_gets_fresh_boot_and_key_from_acm_on_each_connection(self): + # A second connect must not reuse a pin just because USB is unchanged. + first = self.ram_connect(BOOT_A + '\nssh-ed25519 AAAA\n') + pin_a = Path(self.temp.name) / f'known_hosts-{BOOT_A}' + self.assertEqual(pin_a.read_text(), '172.16.42.1 ssh-ed25519 AAAA\n') + self.assertEqual(pin_a.stat().st_mode & 0o777, 0o600) + self.assertEqual(self.ram_connect(BOOT_A + '\nssh-ed25519 AAAA\n'), first) + second = self.ram_connect(BOOT_B + '\nssh-ed25519 BBBB\n') + self.assertNotEqual(first, second) + self.assertFalse(pin_a.exists()) + self.assertEqual((Path(self.temp.name) / f'known_hosts-{BOOT_B}').read_text(), + '172.16.42.1 ssh-ed25519 BBBB\n') + + def test_legacy_ram_boot_tag_is_still_verified_against_acm(self): + self.ram_connect(PAYLOAD['boot_id'] + '\nssh-ed25519 AAAA\n', device=PAYLOAD) + with self.assertRaisesRegex(ValueError, 'disconnected or rebooted'): + self.ram_connect(BOOT_B + '\nssh-ed25519 AAAA\n', device=PAYLOAD) + + def test_invalid_acm_identity_or_changed_key_does_not_reuse_a_pin(self): + self.ram_connect(BOOT_A + '\nssh-ed25519 AAAA\n') + for reply, error in [('garbage\nssh-ed25519 BBBB\n', 'Invalid RAM boot'), + ('../../escape\nssh-ed25519 BBBB\n', 'Invalid RAM boot'), + (BOOT_A + '\n', 'exactly one SSH host key'), + (BOOT_A + '\nssh-ed25519 BBBB\n', 'changed within the same boot')]: + with self.subTest(reply=reply), self.assertRaisesRegex(ValueError, error): + self.ram_connect(reply) + self.assertEqual((Path(self.temp.name) / f'known_hosts-{BOOT_A}').read_text(), + '172.16.42.1 ssh-ed25519 AAAA\n') + + def test_stable_usb_check_rejects_another_unit_or_mode_at_the_same_port(self): + check = ram_session._check(STABLE_PAYLOAD) + with mock.patch('orchard.apple.usb.devices', return_value=[STABLE_PAYLOAD]): + check() + for change in ({'usb_id': 'fedcba987654'}, {'mode': 'system'}, + {'profile': 'j71t'}, {'serial_port': '/dev/cu.other'}): + with self.subTest(change=change), \ + mock.patch('orchard.apple.usb.devices', return_value=[{**STABLE_PAYLOAD, **change}]), \ + self.assertRaisesRegex(ValueError, 'disconnected or rebooted'): + check() + if __name__ == '__main__': unittest.main()