feat: prove and decode K1 realtime MQTT streams

This commit is contained in:
DCCONSTRUCTIONS
2026-07-15 19:10:41 +03:00
parent faa442eefc
commit 6b22e5a1d2
30 changed files with 4375 additions and 41 deletions
+67
View File
@@ -1,4 +1,6 @@
import json
from pathlib import Path
from typing import Any
from typer.testing import CliRunner
@@ -21,3 +23,68 @@ def test_doctor_json() -> None:
assert isinstance(payload["tools"], list)
assert isinstance(payload["network"], dict)
assert any(item["name"] == "tcpdump" for item in payload["tools"])
def test_mqtt_capture_requires_owned_device_confirmation(tmp_path: Path) -> None:
result = runner.invoke(
app,
[
"net",
"mqtt-capture",
"--host",
"192.168.1.20",
"--out",
str(tmp_path / "capture"),
],
)
assert result.exit_code == 2
assert "ownership not confirmed" in result.stdout
assert not (tmp_path / "capture").exists()
def test_mqtt_capture_cli_uses_bounded_read_only_capture(
monkeypatch: Any,
tmp_path: Path,
) -> None:
captured: dict[str, object] = {}
def fake_capture(host: str, out: Path, **kwargs: object) -> dict[str, object]:
on_ready = kwargs.pop("on_ready")
assert callable(on_ready)
on_ready()
captured.update({"host": host, "out": out, **kwargs})
return {
"stop_reason": "duration_elapsed",
"message_count": 2,
"payload_bytes": 128,
}
monkeypatch.setattr("k1link.cli.capture_mqtt", fake_capture)
out = tmp_path / "capture"
result = runner.invoke(
app,
[
"net",
"mqtt-capture",
"--host",
"10.0.0.42",
"--out",
str(out),
"--duration",
"5",
"--max-message-bytes",
"1024",
"--confirm-owned-device",
],
)
assert result.exit_code == 0
assert captured == {
"host": "10.0.0.42",
"out": out,
"port": 1883,
"duration_seconds": 5.0,
"max_message_bytes": 1024,
}
assert "messages: 2" in result.stdout
assert "subscriptions active" in result.stdout
+35
View File
@@ -0,0 +1,35 @@
from unittest.mock import Mock, patch
import pytest
from k1link.macos_credentials import CredentialDialogError, _dialog_text
@patch("k1link.macos_credentials.platform.system", return_value="Darwin")
@patch("k1link.macos_credentials.shutil.which", return_value="/usr/bin/osascript")
@patch("k1link.macos_credentials.subprocess.run")
def test_dialog_returns_value_without_printing(
run: Mock,
_which: Mock,
_system: Mock,
) -> None:
run.return_value = Mock(returncode=0, stdout="local-value\n", stderr="")
assert _dialog_text("Prompt", hidden=True) == "local-value"
command = run.call_args.args[0]
assert "local-value" not in command
assert "with hidden answer" in command[-1]
@patch("k1link.macos_credentials.platform.system", return_value="Darwin")
@patch("k1link.macos_credentials.shutil.which", return_value="/usr/bin/osascript")
@patch("k1link.macos_credentials.subprocess.run")
def test_dialog_cancel_is_not_echoed(
run: Mock,
_which: Mock,
_system: Mock,
) -> None:
run.return_value = Mock(returncode=1, stdout="", stderr="User canceled.")
with pytest.raises(CredentialDialogError, match="cancelled"):
_dialog_text("Prompt", hidden=False)
+209
View File
@@ -0,0 +1,209 @@
import hashlib
import json
import stat
from collections.abc import Callable
from pathlib import Path
from typing import Any, cast
import paho.mqtt.client as mqtt
import pytest
from paho.mqtt.packettypes import PacketTypes
from paho.mqtt.reasoncodes import ReasonCode
from k1link.mqtt.capture import (
FRAME_HEADER,
RAW_MAGIC,
REPORT_TOPICS,
CaptureError,
CaptureFormatError,
capture_mqtt,
iter_capture_frames,
validate_private_ipv4,
)
class FakeClient:
def __init__(self, *, topic: str = "RealtimePath", payload: bytes = b"pose-data") -> None:
self.on_connect: Callable[..., None] | None = None
self.on_subscribe: Callable[..., None] | None = None
self.on_message: Callable[..., None] | None = None
self.on_disconnect: Callable[..., None] | None = None
self.topic = topic
self.payload = payload
self.connect_calls: list[tuple[str, int, int]] = []
self.subscribe_calls: list[Any] = []
self.disconnect_count = 0
self._step = 0
def connect(self, host: str, port: int, keepalive: int) -> mqtt.MQTTErrorCode:
self.connect_calls.append((host, port, keepalive))
return mqtt.MQTT_ERR_SUCCESS
def subscribe(self, topics: Any) -> tuple[mqtt.MQTTErrorCode, int]:
self.subscribe_calls.append(topics)
return mqtt.MQTT_ERR_SUCCESS, 7
def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
assert timeout > 0
self._step += 1
if self._step == 1:
assert self.on_connect is not None
self.on_connect(
self,
None,
mqtt.ConnectFlags(session_present=False),
ReasonCode(PacketTypes.CONNACK, "Success"),
None,
)
elif self._step == 2:
assert self.on_subscribe is not None
self.on_subscribe(
self,
None,
7,
[ReasonCode(PacketTypes.SUBACK, identifier=0) for _ in REPORT_TOPICS],
None,
)
elif self._step == 3:
assert self.on_message is not None
message = mqtt.MQTTMessage(topic=self.topic.encode())
message.payload = self.payload
message.qos = 0
self.on_message(self, None, message)
else:
raise KeyboardInterrupt
return mqtt.MQTT_ERR_SUCCESS
def disconnect(self) -> mqtt.MQTTErrorCode:
self.disconnect_count += 1
return mqtt.MQTT_ERR_SUCCESS
@pytest.mark.parametrize("address", ["10.0.0.1", "172.16.0.1", "172.31.255.254", "192.168.4.2"])
def test_validate_private_ipv4_accepts_only_rfc1918(address: str) -> None:
assert validate_private_ipv4(address) == address
@pytest.mark.parametrize(
"address",
["8.8.8.8", "127.0.0.1", "169.254.1.2", "::1", "k1.local", " 192.168.1.2"],
)
def test_validate_private_ipv4_rejects_other_targets(address: str) -> None:
with pytest.raises(ValueError, match="private IPv4|RFC1918"):
validate_private_ipv4(address)
def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -> None:
fake = FakeClient()
summary = capture_mqtt(
"192.168.1.50",
tmp_path / "capture",
duration_seconds=30,
_client_factory=lambda: cast(mqtt.Client, fake),
)
assert fake.connect_calls == [("192.168.1.50", 1883, 30)]
assert fake.subscribe_calls == [[(topic, 0) for topic in REPORT_TOPICS]]
assert fake.disconnect_count == 1
assert summary["stop_reason"] == "keyboard_interrupt"
assert summary["message_count"] == 1
assert summary["payload_bytes"] == len(fake.payload)
assert summary["subscriptions"] == list(REPORT_TOPICS)
capture_dir = tmp_path / "capture"
raw = (capture_dir / "mqtt.raw.k1mqtt").read_bytes()
assert raw.startswith(RAW_MAGIC)
topic_length, payload_length = FRAME_HEADER.unpack_from(raw, len(RAW_MAGIC))
topic_start = len(RAW_MAGIC) + FRAME_HEADER.size
payload_start = topic_start + topic_length
assert raw[topic_start:payload_start].decode() == fake.topic
assert payload_length == len(fake.payload)
assert raw[payload_start:] == fake.payload
records: list[dict[str, object]] = [
json.loads(line)
for line in (capture_dir / "mqtt.metadata.jsonl").read_text().splitlines()
]
assert len(records) == 1
record = records[0]
assert record["record_type"] == "message"
assert record["topic"] == fake.topic
assert record["payload_bytes"] == len(fake.payload)
assert record["payload_sha256"] == hashlib.sha256(fake.payload).hexdigest()
assert record["raw_frame_offset"] == len(RAW_MAGIC)
assert record["raw_payload_offset"] == payload_start
frames = list(iter_capture_frames(capture_dir / "mqtt.raw.k1mqtt"))
assert len(frames) == 1
assert frames[0].sequence == 1
assert frames[0].topic == fake.topic
assert frames[0].payload == fake.payload
assert frames[0].raw_frame_offset == len(RAW_MAGIC)
assert frames[0].raw_payload_offset == payload_start
saved_summary = json.loads((capture_dir / "mqtt.summary.json").read_text())
assert saved_summary == summary
assert saved_summary["artifact_hashes"]["raw_sha256"] == hashlib.sha256(raw).hexdigest()
for artifact_name in ("mqtt.raw.k1mqtt", "mqtt.metadata.jsonl", "mqtt.summary.json"):
assert stat.S_IMODE((capture_dir / artifact_name).stat().st_mode) == 0o600
def test_oversize_payload_is_not_written_to_raw_capture(tmp_path: Path) -> None:
fake = FakeClient(payload=b"oversize")
capture_dir = tmp_path / "capture"
with pytest.raises(CaptureError, match="limit is 4 bytes") as error:
capture_mqtt(
"10.1.2.3",
capture_dir,
max_message_bytes=4,
_client_factory=lambda: cast(mqtt.Client, fake),
)
assert (capture_dir / "mqtt.raw.k1mqtt").read_bytes() == RAW_MAGIC
record = json.loads((capture_dir / "mqtt.metadata.jsonl").read_text())
assert record["record_type"] == "rejected_message"
assert record["payload_bytes"] == len(fake.payload)
assert error.value.summary is not None
assert error.value.summary["stop_reason"] == "message_too_large"
assert error.value.summary["message_count"] == 0
assert error.value.summary["rejected_message_count"] == 1
def test_capture_refuses_to_overwrite_existing_artifacts(tmp_path: Path) -> None:
capture_dir = tmp_path / "capture"
capture_dir.mkdir()
raw = capture_dir / "mqtt.raw.k1mqtt"
raw.write_bytes(b"existing evidence")
with pytest.raises(FileExistsError, match="refusing to overwrite"):
capture_mqtt(
"192.168.1.2",
capture_dir,
_client_factory=lambda: cast(mqtt.Client, FakeClient()),
)
assert raw.read_bytes() == b"existing evidence"
@pytest.mark.parametrize(
("raw", "message"),
[
(b"not-mqtt", "magic/version"),
(RAW_MAGIC + b"\x00", "truncated length header"),
(RAW_MAGIC + FRAME_HEADER.pack(4, 1) + b"ab", "topic .* is truncated"),
(RAW_MAGIC + FRAME_HEADER.pack(1, 4) + b"t" + b"ab", "payload .* is truncated"),
(RAW_MAGIC + FRAME_HEADER.pack(1, 5) + b"t" + b"abcde", "exceeds 4"),
],
)
def test_capture_reader_rejects_invalid_or_unbounded_frames(
tmp_path: Path,
raw: bytes,
message: str,
) -> None:
path = tmp_path / "capture.raw"
path.write_bytes(raw)
with pytest.raises(CaptureFormatError, match=message):
list(iter_capture_frames(path, max_payload_bytes=4))
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
import math
import struct
import lz4.block
import pytest
from k1link.protocol.protobuf_wire import ProtobufWireError, decode_zigzag64, iter_fields
from k1link.protocol.streams import (
DecodeLimits,
StreamDecodeError,
UnsupportedCompressionError,
decode_legacy_pointcloud,
decode_legacy_pose,
decode_lio_pcl,
decode_lio_pose,
decode_pre_path_array,
)
def _varint(value: int) -> bytes:
encoded = bytearray()
while value > 0x7F:
encoded.append((value & 0x7F) | 0x80)
value >>= 7
encoded.append(value)
return bytes(encoded)
def _key(number: int, wire_type: int) -> bytes:
return _varint((number << 3) | wire_type)
def _uint(number: int, value: int) -> bytes:
return _key(number, 0) + _varint(value)
def _sint(number: int, value: int) -> bytes:
zigzag = (value << 1) ^ (value >> 63)
return _uint(number, zigzag & 0xFFFFFFFFFFFFFFFF)
def _bytes(number: int, value: bytes) -> bytes:
return _key(number, 2) + _varint(len(value)) + value
def _fixed32(number: int, value: float) -> bytes:
return _key(number, 5) + struct.pack("<f", value)
def _fixed64(number: int, value: float) -> bytes:
return _key(number, 1) + struct.pack("<d", value)
def _header(*, scaler: int = 1000) -> bytes:
return b"".join(
(
_uint(1, 7),
_sint(2, 123456),
_sint(3, scaler),
_bytes(4, b"device-redacted"),
_bytes(5, b"session-redacted"),
)
)
def _pcl_payload(*, compression: int = 0, scaler: int = 1000) -> bytes:
point_1 = _sint(1, 1000) + _sint(2, -2000) + _sint(3, 500) + _uint(4, 0x11223344)
point_2 = _sint(1, -250) + _sint(2, 0) + _sint(3, 4000) + _uint(4, 0xAABBCC09)
report = _bytes(1, _header(scaler=scaler)) + _bytes(2, point_1) + _bytes(2, point_2)
compressed = lz4.block.compress(report, store_size=False)
fields = []
if compression:
fields.append(_uint(2, compression))
fields.extend((_uint(3, len(report)), _bytes(4, compressed)))
return b"".join(fields)
def test_protobuf_wire_zigzag_and_bounds() -> None:
assert decode_zigzag64(0) == 0
assert decode_zigzag64(1) == -1
assert decode_zigzag64(2) == 1
with pytest.raises(ProtobufWireError, match="truncated"):
list(iter_fields(b"\x0a\x02\x01"))
with pytest.raises(ProtobufWireError, match="unsupported"):
list(iter_fields(b"\x0b"))
def test_decode_lio_pcl_raw_lz4() -> None:
frame = decode_lio_pcl(_pcl_payload())
assert frame.header.seq == 7
assert frame.header.stamp == 123456
assert frame.header.scaler == 1000
assert len(frame.points) == 2
assert frame.points[0].scaled_xyz(frame.header.scaler) == (1.0, -2.0, 0.5)
assert frame.points[0].rgbi == 0x11223344
assert frame.points[0].intensity == 0x44
assert frame.points[1].scaled_xyz(frame.header.scaler) == (-0.25, 0.0, 4.0)
assert frame.points[1].intensity == 9
def test_decode_lio_pcl_rejects_unverified_or_unsafe_frames() -> None:
with pytest.raises(UnsupportedCompressionError, match="enum 1"):
decode_lio_pcl(_pcl_payload(compression=1))
with pytest.raises(StreamDecodeError, match="scaler is zero"):
decode_lio_pcl(_pcl_payload(scaler=0))
with pytest.raises(StreamDecodeError, match="exceeds 1 points"):
decode_lio_pcl(_pcl_payload(), DecodeLimits(max_points_per_frame=1))
with pytest.raises(StreamDecodeError, match="MQTT payload exceeds"):
decode_lio_pcl(_pcl_payload(), DecodeLimits(max_mqtt_payload_bytes=4))
def test_decode_lio_pose() -> None:
position = _fixed64(1, 1.25) + _fixed64(2, -2.5) + _fixed64(3, 3.75)
orientation = (
_fixed64(1, 0.1)
+ _fixed64(2, 0.2)
+ _fixed64(3, 0.3)
+ _fixed64(4, 0.9)
)
pose = _bytes(1, position) + _bytes(2, orientation)
stamped = _sint(1, 987654321) + _bytes(2, pose)
payload = (
_bytes(1, _header())
+ _bytes(2, stamped)
+ _fixed32(3, 12.5)
+ _fixed32(4, 0.001)
)
frame = decode_lio_pose(payload)
assert frame.pose_stamp == 987654321
assert frame.position_xyz == (1.25, -2.5, 3.75)
assert frame.orientation_xyzw == pytest.approx((0.1, 0.2, 0.3, 0.9))
assert frame.distance == 12.5
assert frame.pose_accuracy == pytest.approx(0.001)
def test_decode_lio_pose_rejects_nonfinite_float() -> None:
payload = _bytes(1, _header()) + _fixed32(3, math.nan)
with pytest.raises(StreamDecodeError, match="not finite"):
decode_lio_pose(payload)
def test_decode_legacy_pointcloud() -> None:
envelope = struct.pack("<III", 16, 123, 456)
body = struct.pack("<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40)
frame = decode_legacy_pointcloud(envelope + body)
assert frame.stride == 16
assert frame.envelope == envelope
assert frame.points[0] == (1.0, -2.0, 3.0, 10, 20, 30, 40)
def test_decode_legacy_pose_and_matrix() -> None:
payload = struct.pack("<ffffffff", 1.0, 2.0, 3.0, 99.0, 0.9, 0.1, 0.2, 0.3)
frame = decode_legacy_pose(payload + b"tail")
assert frame.position_xyz == (1.0, 2.0, 3.0)
assert frame.orientation_xyzw == pytest.approx((0.1, 0.2, 0.3, 0.9))
assert frame.skipped_offset_12 == struct.pack("<f", 99.0)
assert frame.unknown_tail == b"tail"
matrix = tuple(float(index) for index in range(16))
assert decode_pre_path_array(struct.pack("<16d", *matrix)) == matrix
with pytest.raises(StreamDecodeError, match="exactly 128"):
decode_pre_path_array(b"short")
+195
View File
@@ -0,0 +1,195 @@
from __future__ import annotations
import hashlib
import json
import stat
import struct
from pathlib import Path
import lz4.block
import pytest
from typer.testing import CliRunner
from k1link.analyze.stream_summary import summarize_mqtt_streams
from k1link.cli import app
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC, CaptureFormatError
runner = CliRunner()
def _varint(value: int) -> bytes:
encoded = bytearray()
while value > 0x7F:
encoded.append((value & 0x7F) | 0x80)
value >>= 7
encoded.append(value)
return bytes(encoded)
def _key(number: int, wire_type: int) -> bytes:
return _varint((number << 3) | wire_type)
def _uint(number: int, value: int) -> bytes:
return _key(number, 0) + _varint(value)
def _sint(number: int, value: int) -> bytes:
zigzag = (value << 1) ^ (value >> 63)
return _uint(number, zigzag & 0xFFFFFFFFFFFFFFFF)
def _bytes(number: int, value: bytes) -> bytes:
return _key(number, 2) + _varint(len(value)) + value
def _fixed64(number: int, value: float) -> bytes:
return _key(number, 1) + struct.pack("<d", value)
def _header(*, scaler: int) -> bytes:
return b"".join(
(
_uint(1, 7),
_sint(2, 123456),
_sint(3, scaler),
_bytes(4, b"device-super-secret"),
_bytes(5, b"session-super-secret"),
_bytes(6, b"openapi-super-secret"),
)
)
def _pcl_payload(*, scaler: int, point_count: int) -> bytes:
points = []
for index in range(point_count):
point = (
_sint(1, 1000 + index)
+ _sint(2, -2000 - index)
+ _sint(3, 500 + index)
+ _uint(4, index)
)
points.append(_bytes(2, point))
report = _bytes(1, _header(scaler=scaler)) + b"".join(points)
compressed = lz4.block.compress(report, store_size=False)
return _uint(3, len(report)) + _bytes(4, compressed)
def _pose_payload(position_xyz: tuple[float, float, float]) -> bytes:
position = b"".join(
_fixed64(field_number, value)
for field_number, value in enumerate(position_xyz, start=1)
)
orientation = (
_fixed64(1, 0.0)
+ _fixed64(2, 0.0)
+ _fixed64(3, 0.0)
+ _fixed64(4, 1.0)
)
pose = _bytes(1, position) + _bytes(2, orientation)
stamped = _sint(1, 987654321) + _bytes(2, pose)
return _bytes(1, _header(scaler=1000)) + _bytes(2, stamped)
def _write_capture(path: Path, frames: list[tuple[str, bytes]]) -> bytes:
raw = bytearray(RAW_MAGIC)
for topic, payload in frames:
topic_bytes = topic.encode()
raw.extend(FRAME_HEADER.pack(len(topic_bytes), len(payload)))
raw.extend(topic_bytes)
raw.extend(payload)
value = bytes(raw)
path.write_bytes(value)
return value
def test_summary_is_bounded_aggregate_only_and_hashes_capture(tmp_path: Path) -> None:
frames = [
("lixel/application/report/lio_pcl", _pcl_payload(scaler=1000, point_count=2)),
("lixel/application/report/lio_pcl", _pcl_payload(scaler=2000, point_count=1)),
("lixel/application/report/lio_pcl", b"bad-protobuf"),
("lixel/application/report/lio_pose", _pose_payload((1.0, 2.0, 3.0))),
("lixel/application/report/lio_pose", _pose_payload((4.0, 6.0, 3.0))),
("lixel/application/report/lio_pose", b"bad-protobuf"),
("device-super-secret/session-super-secret", b"openapi-super-secret"),
]
capture = tmp_path / "mqtt.raw.k1mqtt"
raw = _write_capture(capture, frames)
summary = summarize_mqtt_streams(capture)
assert summary["source"] == {
"bytes": len(raw),
"sha256": hashlib.sha256(raw).hexdigest(),
}
assert summary["frames"] == {
"count": 7,
"payload_bytes": sum(len(payload) for _, payload in frames),
"encoded_frame_bytes": len(raw) - len(RAW_MAGIC),
"other_count": 1,
"other_payload_bytes": len(b"openapi-super-secret"),
}
assert summary["decoding"] == {"attempted": 6, "successes": 4, "errors": 2}
assert summary["point_cloud"]["frame_count"] == 3
assert summary["point_cloud"]["decode_successes"] == 2
assert summary["point_cloud"]["decode_errors"] == 1
assert summary["point_cloud"]["points"] == {
"total": 3,
"per_frame": {"min": 1, "max": 2},
}
assert summary["point_cloud"]["scalers"] == {
"min": 1000,
"max": 2000,
"constant": False,
}
assert summary["pose"]["frame_count"] == 3
assert summary["pose"]["decode_successes"] == 2
assert summary["pose"]["decode_errors"] == 1
assert summary["pose"]["first_to_last_displacement_meters"] == 5.0
serialized = json.dumps(summary)
for secret in (
"device-super-secret",
"session-super-secret",
"openapi-super-secret",
"bad-protobuf",
):
assert secret not in serialized
assert "position_xyz" not in serialized
assert "points" in summary["point_cloud"]
def test_summary_rejects_a_frame_above_the_operator_limit(tmp_path: Path) -> None:
capture = tmp_path / "mqtt.raw.k1mqtt"
_write_capture(capture, [("other", b"12345")])
with pytest.raises(CaptureFormatError, match="exceeds 4"):
summarize_mqtt_streams(capture, max_payload_bytes=4)
def test_mqtt_streams_cli_writes_atomic_private_summary(tmp_path: Path) -> None:
capture = tmp_path / "mqtt.raw.k1mqtt"
raw = _write_capture(capture, [])
out = tmp_path / "captures" / "summary.json"
result = runner.invoke(
app,
[
"analyze",
"mqtt-streams",
"--capture",
str(capture),
"--out",
str(out),
"--max-payload-bytes",
"1024",
],
)
assert result.exit_code == 0
saved = json.loads(out.read_text())
assert saved["source"]["sha256"] == hashlib.sha256(raw).hexdigest()
assert saved["limits"]["max_payload_bytes"] == 1024
assert saved["frames"]["count"] == 0
assert "aggregate-only" in result.stdout
assert stat.S_IMODE(out.stat().st_mode) == 0o600
+251
View File
@@ -0,0 +1,251 @@
from __future__ import annotations
import json
import plistlib
from pathlib import Path
import pytest
from typer.testing import CliRunner
from k1link.cli import app
from k1link.usb.snapshot import (
DISKUTIL_COMMAND,
SERIAL_IOREG_COMMAND,
USB_IOREG_COMMAND,
CommandOutput,
snapshot,
)
runner = CliRunner()
def _plist_output(argv: tuple[str, ...], payload: object) -> CommandOutput:
return CommandOutput(
argv=argv,
returncode=0,
stdout=plistlib.dumps(payload),
stderr=b"",
error=None,
)
def _interface(
name: str,
number: int,
interface_class: int,
subclass: int,
protocol: int,
) -> dict[str, object]:
return {
"IOObjectClass": "IOUSBHostInterface",
"IORegistryEntryName": name,
"kUSBString": name,
"bInterfaceNumber": number,
"bInterfaceClass": interface_class,
"bInterfaceSubClass": subclass,
"bInterfaceProtocol": protocol,
"bAlternateSetting": 0,
"bConfigurationValue": 1,
"bNumEndpoints": 2,
}
def test_snapshot_parses_xgrids_interfaces_storage_and_usbmodem() -> None:
usb_plist = [
{
"IOObjectClass": "IOUSBHostDevice",
"IORegistryEntryName": "XGRIDS Device",
"USB Product Name": "XGRIDS Device",
"USB Vendor Name": "rockchip",
"USB Serial Number": "synthetic-serial",
"idVendor": 0x2207,
"idProduct": 0x0019,
"bDeviceClass": 0xEF,
"bDeviceSubClass": 0x02,
"bDeviceProtocol": 0x01,
"bcdUSB": 0x0210,
"bcdDevice": 0x0310,
"USBSpeed": 3,
"UsbLinkSpeed": 480_000_000,
"USB Address": 1,
"locationID": 0x01100000,
"IORegistryEntryID": 12345,
"IORegistryEntryChildren": [
_interface("RNDIS Communications Control", 0, 0xE0, 0x01, 0x03),
_interface("RNDIS Ethernet Data", 1, 0x0A, 0x00, 0x00),
_interface("Mass Storage", 2, 0x08, 0x06, 0x50),
_interface("CDC NCM Control", 3, 0x02, 0x0D, 0x00),
_interface("CDC ACM Serial", 4, 0x02, 0x02, 0x01),
{
"IOObjectClass": "IOMedia",
"IORegistryEntryName": "Synthetic Media",
"BSD Name": "disk4",
},
],
},
{
"IOObjectClass": "IOUSBHostDevice",
"IORegistryEntryName": "Unrelated Camera",
"USB Product Name": "Unrelated Camera",
"idVendor": 999,
},
]
serial_plist = [
{
"IOCalloutDevice": "/dev/cu.usbmodemK1TEST",
"IODialinDevice": "/dev/tty.usbmodemK1TEST",
"IOTTYBaseName": "usbmodemK1TEST",
"IOSerialBSDClientType": "IOSerialStream",
},
{
"IOCalloutDevice": "/dev/cu.debug-console",
"IODialinDevice": "/dev/tty.debug-console",
},
]
storage_plist = {
"AllDisks": ["disk4", "disk4s1"],
"WholeDisks": ["disk4"],
"VolumesFromDisks": ["K1_DATA"],
"AllDisksAndPartitions": [
{
"DeviceIdentifier": "disk4",
"Content": "GUID_partition_scheme",
"Size": 64_000_000,
"OSInternal": False,
"Partitions": [
{
"DeviceIdentifier": "disk4s1",
"Content": "Microsoft Basic Data",
"Size": 63_000_000,
"VolumeName": "K1_DATA",
"MountPoint": "/Volumes/K1_DATA",
"OSInternal": False,
}
],
}
],
}
outputs = {
USB_IOREG_COMMAND: _plist_output(USB_IOREG_COMMAND, usb_plist),
SERIAL_IOREG_COMMAND: _plist_output(SERIAL_IOREG_COMMAND, serial_plist),
DISKUTIL_COMMAND: _plist_output(DISKUTIL_COMMAND, storage_plist),
}
result = snapshot(lambda argv: outputs[tuple(argv)])
assert result["xgrids_device_count"] == 1
device = result["xgrids_devices"][0]
assert device["vendor_id_hex"] == "0x2207"
assert device["product_id_hex"] == "0x0019"
assert device["bsd_names"] == ["disk4"]
assert device["interface_capabilities"] == [
"cdc_data",
"mass_storage",
"ncm",
"rndis",
"serial",
]
assert [interface["interface_number"] for interface in device["interfaces"]] == [
0,
1,
2,
3,
4,
]
assert all(entry["xgrids_related"] for entry in result["external_storage"]["entries"])
assert result["usbmodem_device_names"] == [
"/dev/cu.usbmodemK1TEST",
"/dev/tty.usbmodemK1TEST",
]
assert len(result["usbmodem_devices"]) == 1
assert all(source["ok"] for source in result["sources"])
assert all("sudo" not in source["argv"] for source in result["sources"])
assert result["safety"] == {
"metadata_only": True,
"sudo_used": False,
"device_file_contents_read": False,
"device_writes_performed": False,
}
def test_snapshot_reports_command_and_plist_errors_without_raising() -> None:
outputs = {
USB_IOREG_COMMAND: CommandOutput(
argv=USB_IOREG_COMMAND,
returncode=0,
stdout=b"not a plist",
stderr=b"",
error=None,
),
SERIAL_IOREG_COMMAND: CommandOutput(
argv=SERIAL_IOREG_COMMAND,
returncode=1,
stdout=b"",
stderr=b"serial registry unavailable",
error=None,
),
DISKUTIL_COMMAND: _plist_output(
DISKUTIL_COMMAND,
{
"AllDisks": [],
"WholeDisks": [],
"VolumesFromDisks": [],
"AllDisksAndPartitions": [],
},
),
}
result = snapshot(lambda argv: outputs[tuple(argv)])
assert result["xgrids_devices"] == []
statuses = {source["name"]: source for source in result["sources"]}
assert statuses["usb_ioreg"]["ok"] is False
assert statuses["usb_ioreg"]["error"].startswith("invalid plist:")
assert statuses["serial_ioreg"]["ok"] is False
assert statuses["serial_ioreg"]["error"] == "serial registry unavailable"
assert statuses["external_disks"]["ok"] is True
def test_snapshot_treats_empty_ioreg_output_as_no_devices() -> None:
outputs = {
USB_IOREG_COMMAND: CommandOutput(USB_IOREG_COMMAND, 0, b"", b"", None),
SERIAL_IOREG_COMMAND: CommandOutput(SERIAL_IOREG_COMMAND, 0, b"", b"", None),
DISKUTIL_COMMAND: _plist_output(
DISKUTIL_COMMAND,
{
"AllDisks": [],
"WholeDisks": [],
"VolumesFromDisks": [],
"AllDisksAndPartitions": [],
},
),
}
result = snapshot(lambda argv: outputs[tuple(argv)])
assert result["xgrids_device_count"] == 0
assert all(source["ok"] for source in result["sources"])
def test_usb_snapshot_cli_writes_json(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
output = tmp_path / "nested" / "usb-snapshot.json"
payload = {
"schema_version": 1,
"xgrids_device_count": 0,
"safety": {
"metadata_only": True,
"sudo_used": False,
"device_file_contents_read": False,
"device_writes_performed": False,
},
}
monkeypatch.setattr("k1link.cli.usb_snapshot", lambda: payload)
result = runner.invoke(app, ["usb", "snapshot", "--out", str(output)])
assert result.exit_code == 0
assert json.loads(output.read_text(encoding="utf-8")) == payload
assert "no sudo" in result.stdout
+69
View File
@@ -0,0 +1,69 @@
import pytest
from k1link.ble.wifi_provisioning import (
FRAME_LENGTH,
build_wifi_provisioning_frame,
parse_wifi_status,
)
def test_build_wifi_provisioning_frame_layout() -> None:
frame = build_wifi_provisioning_frame("LabNet", "correct horse")
assert len(frame) == FRAME_LENGTH
assert frame[0] == 6
assert frame[1:7] == b"LabNet"
assert frame[7:33] == bytes(26)
assert frame[33] == 13
assert frame[34:47] == b"correct horse"
assert frame[47:98] == bytes(51)
assert frame[98] == 0
def test_build_wifi_provisioning_frame_uses_utf8_byte_lengths() -> None:
frame = build_wifi_provisioning_frame("Сеть", "пароль")
assert frame[0] == len("Сеть".encode())
assert frame[33] == len("пароль".encode())
@pytest.mark.parametrize(
("ssid", "password", "message"),
[
("", "password", "SSID must not be empty"),
("network", "", "password must not be empty"),
("x" * 33, "password", "at most 32 UTF-8 bytes"),
("network", "x" * 65, "at most 64 UTF-8 bytes"),
],
)
def test_build_wifi_provisioning_frame_rejects_invalid_lengths(
ssid: str,
password: str,
message: str,
) -> None:
with pytest.raises(ValueError, match=message):
build_wifi_provisioning_frame(ssid, password)
def test_parse_wifi_status_ap_baseline() -> None:
value = bytearray(54)
value[0] = 7
value[1:8] = b"WIFI_AP"
value[33] = 4
value[34:38] = bytes((192, 168, 56, 1))
value[50] = 1
value[52:54] = b"XX"
assert parse_wifi_status(bytes(value)) == {
"value_length": 54,
"mode": "WIFI_AP",
"ipv4": "192.168.56.1",
"status_code": 1,
"reserved": 0,
"trailer_hex": "5858",
}
def test_parse_wifi_status_rejects_short_frame() -> None:
with pytest.raises(ValueError, match="at least 51 bytes"):
parse_wifi_status(bytes(50))