Files
StarPilot/openpilot/starpilot/galaxy_bluetooth/tests/test_bridge.py
T

363 lines
17 KiB
Python

import asyncio
import base64
import json
from pathlib import Path
import struct
import zlib
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "bridge"))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "vendor"))
from dbus_next import Variant, DBusError
from Crypto.Cipher import AES
from protocol import (Assembler, MAX_BODY, MAX_FRAME, NOTIFICATION_WINDOW, open_message,
packets, seal, stream_tag, notification_packets)
from server import Advertisement, check_adapter, Application, Characteristic, NotificationCharacteristic, GattService, Gateway, SERVICE_PATH
KEY = bytes(range(32))
class ProtocolTests(unittest.TestCase):
def test_notification_packet_tags_binary_and_mtu(self):
tag = stream_tag("01" * 16, 1)
self.assertNotEqual(tag, stream_tag("01" * 16, 2))
self.assertNotEqual(tag, stream_tag("02" * 16, 1))
value = {"body": base64.b64encode(bytes(range(256)) * 100).decode(), "status": 200}
frame = seal(value, KEY, "response", compact_body=True)
for size in (20, 180, 244, 512):
assembler = Assembler()
for packet in notification_packets(frame, tag, size):
self.assertLessEqual(len(packet), size)
self.assertEqual(packet[1:9], tag)
decoded = assembler.add(b"\x01" + packet[9:])
self.assertEqual(open_message(decoded, KEY, "response"), value)
def test_roundtrip_all_att_sizes_and_binary_body(self):
value = {"body": base64.b64encode(bytes(range(256)) * 32).decode(), "unicode": "Galaxy ✨", "status": 200}
frame = seal(value, KEY, "response")
for size in (20, 64, 180, 244, 512):
assembler = Assembler()
decoded = None
for packet in packets(frame, size):
self.assertLessEqual(len(packet), size)
decoded = assembler.add(packet)
self.assertEqual(open_message(decoded, KEY, "response"), value)
def test_ciphertext_tamper_wrong_key_and_direction(self):
message = seal({"id": "test"}, KEY, "request")[4:]
for data, key, direction in [(message[:-1] + bytes([message[-1] ^ 1]), KEY, "request"),
(message, bytes(32), "request"), (message, KEY, "response")]:
with self.assertRaises(ValueError):
open_message(data, key, direction)
def test_compression_and_bounded_frames(self):
frame = seal({"data": "parameter" * 20000}, KEY, "response")
self.assertLess(len(frame), 1000)
with self.assertRaises(ValueError):
Assembler().add(b"\x01" + struct.pack(">I", 0) + struct.pack(">I", MAX_FRAME + 1))
with self.assertRaises(ValueError):
Assembler().add(b"\x01" + struct.pack(">I", 1) + b"bad")
def test_reject_trailing_frame_bytes(self):
frame = seal({"id": "a"}, KEY, "request")
with self.assertRaises(ValueError):
Assembler().add(b"\x01" + bytes(4) + frame + b"extra")
def test_compact_response_preserves_json_and_binary(self):
for body in (json.dumps([{ "repeated_setting": n, "label": "Galaxy ✨" } for n in range(1000)]).encode(),
bytes(range(256)) * 100, b""):
value = {"id": "compact", "status": 200, "headers": {"content-type": "application/json"},
"body": base64.b64encode(body).decode()}
legacy = seal(value, KEY, "response")
compact = seal(value, KEY, "response", compact_body=True)
self.assertLessEqual(len(compact), len(legacy))
self.assertEqual(open_message(compact[4:], KEY, "response"), value)
with self.assertRaises(ValueError):
seal({"body": base64.b64encode(b"x" * (MAX_BODY + 1)).decode()}, KEY, "response", compact_body=True)
def test_compact_response_rejects_bad_metadata_and_expansion(self):
for payload in (b"bad", struct.pack(">I", 50) + b"{}", struct.pack(">I", 2) + b"[]",
struct.pack(">I", 11) + b'{"body":""}',
struct.pack(">I", 2) + b"{}" + b"x" * (MAX_BODY + 1)):
encoded = b"\x02" + struct.pack(">I", len(payload)) + zlib.compress(payload, wbits=-15)
cipher = AES.new(KEY, AES.MODE_GCM, nonce=bytes(12))
cipher.update(b"galaxy-ble-v1/response")
ciphertext, tag = cipher.encrypt_and_digest(encoded)
with self.assertRaises(ValueError):
open_message(bytes(cipher.nonce) + ciphertext + tag, KEY, "response")
class CountingProxy:
def __init__(self):
self.calls = 0
def handle(self, request):
self.calls += 1
return {"id": request["id"], "status": 200, "headers": {}, "body": ""}
class GatewayTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.proxy = CountingProxy()
self.gateway = Gateway(KEY, self.proxy)
self.options = {"device": Variant("o", "/org/bluez/hci0/dev_01_02_03_04_05_06"), "mtu": Variant("q", 185)}
self.state = self.gateway.session(self.options, fresh=True)
async def submit(self, counter=1, session=None, key=KEY, response_codec=None, response_flow=None):
request = {"id": "unique", "session": session or self.state.challenge.hex(), "counter": counter,
"path": "/api/params", "method": "PUT", "body": ""}
if response_codec is not None:
request["responseCodec"] = response_codec
if response_flow is not None:
request["responseFlow"] = response_flow
for packet in packets(seal(request, key, "request")):
self.gateway.write(packet, self.options)
task = self.state.task
await task
def drain(self):
assembler = Assembler()
decoded = None
while self.state.response:
packet = self.gateway.read(self.options)
self.assertEqual(self.gateway.read(self.options), packet) # Read is repeatable until ACK.
decoded = assembler.add(packet)
self.gateway.write(b"\x02" + packet[1:5], self.options)
return open_message(decoded, KEY, "response") if decoded else None
async def test_actual_gatt_request_ack_and_replay(self):
await self.submit()
reply = self.drain()
self.assertEqual(reply["counter"], 1)
self.assertEqual(self.proxy.calls, 1)
await self.submit() # Same session + counter cannot mutate twice.
self.assertFalse(self.state.response)
self.assertEqual(self.proxy.calls, 1)
await self.submit(counter=2)
self.drain()
self.assertEqual(self.proxy.calls, 2)
async def test_wrong_key_and_old_session_never_reach_galaxy(self):
await self.submit(key=bytes(32))
await self.submit(session="00" * 16)
self.assertEqual(self.proxy.calls, 0)
self.assertFalse(self.state.response)
async def test_dbus_exports_and_mtu_minimum(self):
characteristic = Characteristic("uuid", ["read"], self.gateway, "tx")
service = GattService()
application = Application({SERVICE_PATH: service})
self.assertIn(SERVICE_PATH, application.GetManagedObjects.__wrapped__(application))
self.assertEqual(characteristic.props()["Flags"].value, ["read"])
self.options["mtu"] = Variant("q", 23)
await self.submit()
self.assertTrue(all(len(p) <= 20 for p in self.state.response))
self.drain()
async def test_fast_packets_require_capability_and_keep_negotiated_mtu(self):
self.options["mtu"] = Variant("q", 517)
self.gateway.session(self.options)
no_mtu = {"device": self.options["device"]}
self.assertEqual(self.gateway.session(no_mtu).packet_size, 512)
body = json.dumps([{ "repeated_setting": n, "label": "Galaxy ✨" } for n in range(1000)]).encode()
def response(request):
return {"id": request["id"], "status": 200, "headers": {}, "body": base64.b64encode(body).decode()}
self.proxy.handle = response # ty: ignore[invalid-assignment]
await self.submit()
self.assertTrue(all(len(p) <= 180 for p in self.state.response))
legacy_count = len(self.state.response)
self.drain()
await self.submit(counter=2, response_codec=2)
self.assertEqual(len(self.state.response[0]), 512)
self.assertLess(len(self.state.response), legacy_count)
self.assertEqual(base64.b64decode(self.drain()["body"]), body)
self.options["mtu"] = Variant("q", 23)
await self.submit(counter=3, response_codec=2)
self.assertTrue(all(len(p) <= 20 for p in self.state.response))
self.drain()
async def test_stream_reads_once_then_acknowledges_verified_frame(self):
body = json.dumps([{ "repeated_setting": n } for n in range(1000)]).encode()
def response(request):
return {"id": request["id"], "status": 200, "headers": {}, "body": base64.b64encode(body).decode()}
self.proxy.handle = response # ty: ignore[invalid-assignment]
await self.submit(response_codec=2, response_flow="read-stream")
count = len(self.state.response)
self.assertGreater(count, 1)
assembler = Assembler()
for sequence in range(count):
packet = self.gateway.read(self.options)
self.assertEqual(struct.unpack(">I", packet[1:5])[0], sequence)
data = assembler.add(packet)
if sequence == 0:
with self.assertRaises(DBusError):
self.gateway.write(b"\x02" + packet[1:5], self.options)
self.assertEqual(self.gateway.read(self.options), b"\x00")
self.assertTrue(self.state.busy)
self.assertEqual(base64.b64decode(open_message(data, KEY, "response")["body"]), body)
self.gateway.write(b"\x02" + struct.pack(">I", count - 1), self.options)
self.assertFalse(self.state.response)
self.assertFalse(self.state.busy)
def notifications(self):
notifier = NotificationCharacteristic(self.gateway)
sent = []
def changed(values, *args):
if "Value" in values:
sent.append(values["Value"])
notifier.emit_properties_changed = changed
NotificationCharacteristic.StartNotify.__wrapped__(notifier)
self.assertTrue(notifier.props()["Notifying"].value)
self.assertEqual(notifier.props()["Flags"].value, ["notify"])
return notifier, sent
async def wait_for_packets(self, sent, count):
async def wait():
while len(sent) < count: # noqa: ASYNC110 -- polls a list filled by a test stub
await asyncio.sleep(0.001)
await asyncio.wait_for(wait(), timeout=2)
def notification_body(self):
body = json.dumps([{ "repeated_setting": n, "label": "Galaxy ✨" } for n in range(2000)]).encode()
def response(request):
self.proxy.calls += 1
return {"id": request["id"], "status": 200, "headers": {}, "body": base64.b64encode(body).decode()}
self.proxy.handle = response # ty: ignore[invalid-assignment]
return body
async def test_notifications_are_bounded_and_verified_before_final_ack(self):
notifier, sent = self.notifications()
body = self.notification_body()
self.options["mtu"] = Variant("q", 517)
await self.submit(response_codec=2, response_flow="notify-window8")
publisher = self.state.notification_task
count = len(self.state.response)
self.assertGreater(count, NOTIFICATION_WINDOW)
await self.wait_for_packets(sent, NOTIFICATION_WINDOW)
await asyncio.sleep(0.03)
self.assertEqual(len(sent), NOTIFICATION_WINDOW, "Publisher must wait for window credit")
tag = self.state.notification_tag
for bad in (b"\x04" + bytes(8) + struct.pack(">I", 7),
b"\x04" + tag + struct.pack(">I", 0), b"\x02" + struct.pack(">I", 7)):
with self.assertRaises(DBusError):
self.gateway.write(bad, self.options)
self.assertEqual(self.gateway.read(self.options), b"\x00")
assembler = Assembler()
cursor = 0
while cursor < count:
end = min(cursor + NOTIFICATION_WINDOW, count)
await self.wait_for_packets(sent, end)
for packet in sent[cursor:end]:
self.assertLessEqual(len(packet), 512)
data = assembler.add(b"\x01" + packet[9:])
self.assertTrue(self.state.busy)
if end == count:
response = open_message(data, KEY, "response")
self.assertEqual(base64.b64decode(response["body"]), body)
self.gateway.write(b"\x04" + tag + struct.pack(">I", end - 1), self.options)
cursor = end
await publisher
self.assertFalse(self.state.busy)
self.assertEqual(self.proxy.calls, 1)
await self.submit(response_codec=2, response_flow="notify-window8")
self.assertEqual(self.proxy.calls, 1, "A replay cannot execute the setting change twice")
self.assertFalse(self.state.response)
async def test_no_notifications_or_proxy_call_before_subscription_and_authentication(self):
await self.submit(response_codec=2, response_flow="notify-window8")
self.assertEqual(self.proxy.calls, 0)
notifier, sent = self.notifications()
await self.submit(response_codec=2, response_flow="notify-window8", key=bytes(32))
await self.submit(response_codec=2, response_flow="notify-window8", session="00" * 16)
self.assertFalse(sent)
self.assertEqual(self.proxy.calls, 0)
async def test_notification_subscription_end_and_fresh_session_cancel_without_retry(self):
notifier, sent = self.notifications()
self.notification_body()
await self.submit(response_codec=2, response_flow="notify-window8")
publisher = self.state.notification_task
await self.wait_for_packets(sent, NOTIFICATION_WINDOW)
NotificationCharacteristic.StopNotify.__wrapped__(notifier)
with self.assertRaises(asyncio.CancelledError):
await publisher
self.assertTrue(self.state.busy)
self.assertEqual(self.proxy.calls, 1)
new_state = self.gateway.session(self.options, fresh=True)
self.assertTrue(self.state.closed)
self.assertFalse(new_state.busy)
self.assertNotEqual(self.state.challenge, new_state.challenge)
with self.assertRaises(DBusError):
self.gateway.write(b"\x04" + self.state.notification_tag + struct.pack(">I", 7), self.options)
async def test_missing_notification_ack_stops_at_one_window_without_repeating_mutation(self):
notifier, sent = self.notifications()
self.notification_body()
with patch("server.NOTIFICATION_ACK_TIMEOUT", 0.03):
await self.submit(response_codec=2, response_flow="notify-window8")
publisher = self.state.notification_task
await publisher
self.assertEqual(len(sent), NOTIFICATION_WINDOW)
self.assertTrue(self.state.busy)
self.assertEqual(self.proxy.calls, 1)
with self.assertRaises(DBusError):
await self.submit(counter=2, response_codec=2, response_flow="notify-window8")
self.assertEqual(self.proxy.calls, 1)
async def test_clients_have_distinct_tags_and_serialized_publishers(self):
notifier, sent = self.notifications()
self.notification_body()
await self.submit(response_codec=2, response_flow="notify-window8")
first_task = self.state.notification_task
first_count = len(self.state.response)
other_options = {"device": Variant("o", "/org/bluez/hci0/dev_11_12_13_14_15_16"), "mtu": Variant("q", 185)}
other = self.gateway.session(other_options, fresh=True)
request = {"id": "other", "session": other.challenge.hex(), "counter": 1, "method": "PUT", "path": "/api/params",
"body": "", "responseCodec": 2, "responseFlow": "notify-window8"}
for packet in packets(seal(request, KEY, "request")):
self.gateway.write(packet, other_options)
await other.task
second_task = other.notification_task
self.assertNotEqual(self.state.notification_tag, other.notification_tag)
offset = 0
for state, options, count in [(self.state, self.options, first_count), (other, other_options, len(other.response))]:
cursor = 0
while cursor < count:
end = min(cursor + NOTIFICATION_WINDOW, count)
await self.wait_for_packets(sent, offset + end)
window = sent[offset + cursor:offset + end]
self.assertTrue(all(p[1:9] == state.notification_tag for p in window))
if state is self.state:
with self.assertRaises(DBusError):
self.gateway.write(b"\x04" + state.notification_tag + struct.pack(">I", end - 1), other_options)
self.gateway.write(b"\x04" + state.notification_tag + struct.pack(">I", end - 1), options)
cursor = end
offset += count
await first_task
await second_task
self.assertEqual(self.proxy.calls, 2)
if __name__ == "__main__":
unittest.main()
class AdapterHealthTests(unittest.IsolatedAsyncioTestCase):
async def test_registered_advertisement_and_power_on_are_required(self):
from unittest.mock import AsyncMock
properties = type('Properties', (), {})()
properties.call_get = AsyncMock(return_value=Variant('b', True))
advertisement = Advertisement()
await check_adapter(properties, advertisement)
advertisement.released = True
with self.assertRaisesRegex(RuntimeError, 'released'):
await check_adapter(properties, advertisement)
advertisement.released = False
properties.call_get.return_value = Variant('b', False)
with self.assertRaisesRegex(RuntimeError, 'switched off'):
await check_adapter(properties, advertisement)
properties.call_get.side_effect = ConnectionError('BlueZ owner disappeared')
with self.assertRaises(ConnectionError):
await check_adapter(properties, advertisement)