From 1c49d0ed51a9e8c331d0727849bb41617e91a56c Mon Sep 17 00:00:00 2001 From: athexweb3 Date: Sat, 27 Jun 2026 16:50:17 +0600 Subject: [PATCH] feat(interpose): route central clients ObjC swizzles of the CoreBluetooth central surface, a shadow registry, and a loopback transport reusing the C codec; host events become delegate callbacks. No key material crosses it. --- .clang-tidy | 19 +- CMakeLists.txt | 2 +- packages/interpose/CMakeLists.txt | 42 +- packages/interpose/README.md | 10 +- packages/interpose/include/simble_interpose.h | 52 +- packages/interpose/src/entry.c | 29 +- packages/interpose/src/hooks/central_hooks.m | 580 ++++++++++++++++++ packages/interpose/src/registry/shadow.h | 167 +++++ packages/interpose/src/registry/shadow.m | 281 +++++++++ packages/interpose/src/transport/client.c | 296 +++++++++ packages/interpose/src/transport/client.h | 223 +++++++ packages/interpose/tests/client_roundtrip.c | 368 +++++++++++ packages/interpose/tests/hook_smoke.c | 34 + packages/interpose/tests/passthrough_test.m | 68 ++ .../interpose/tests/run-mechanism-central.sh | 31 + .../interpose/tests/shadow_registry_test.m | 103 ++++ 16 files changed, 2289 insertions(+), 16 deletions(-) create mode 100644 packages/interpose/src/hooks/central_hooks.m create mode 100644 packages/interpose/src/registry/shadow.h create mode 100644 packages/interpose/src/registry/shadow.m create mode 100644 packages/interpose/src/transport/client.c create mode 100644 packages/interpose/src/transport/client.h create mode 100644 packages/interpose/tests/client_roundtrip.c create mode 100644 packages/interpose/tests/passthrough_test.m create mode 100755 packages/interpose/tests/run-mechanism-central.sh create mode 100644 packages/interpose/tests/shadow_registry_test.m diff --git a/.clang-tidy b/.clang-tidy index a0eb3eb..d358575 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -1,9 +1,14 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: 2026 Nirapod Labs - -Checks: > - clang-analyzer-*, - bugprone-*, - performance-*, - readability-* -WarningsAsErrors: '' +# +# Curated for a low-level C wire codec and an interposer. The bug-finding +# families stay on (clang-analyzer, bugprone, performance). Off: +# readability-identifier-length one-letter loop/byte vars are idiomatic here +# readability-magic-numbers the CBOR byte constants are the wire format +# readability-braces-around-statements clashes with .clang-format compact returns +# bugprone-easily-swappable-parameters flags ordinary (buf,len)/(ptr,count) C +# ...insecureAPI.DeprecatedOrUnsafeBufferHandling wants C11 Annex K _s funcs, absent on Darwin +# Test and demo harnesses relax two more checks; see packages/*/tests/.clang-tidy. +Checks: "clang-analyzer-*,bugprone-*,-bugprone-easily-swappable-parameters,performance-*,readability-*,-readability-identifier-length,-readability-magic-numbers,-readability-braces-around-statements,-clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling" +WarningsAsErrors: "" +HeaderFilterRegex: "packages/interpose/(src|include)/.*" diff --git a/CMakeLists.txt b/CMakeLists.txt index 010ce3e..ced6b4c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,7 +2,7 @@ # SPDX-FileCopyrightText: 2026 Nirapod Labs cmake_minimum_required(VERSION 3.24) -project(SimBLE LANGUAGES C) +project(SimBLE LANGUAGES C OBJC) set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) diff --git a/packages/interpose/CMakeLists.txt b/packages/interpose/CMakeLists.txt index f59f801..8c302e2 100644 --- a/packages/interpose/CMakeLists.txt +++ b/packages/interpose/CMakeLists.txt @@ -7,7 +7,24 @@ else() set(SIMBLE_INTERPOSE_OUTPUT "simble-interpose") endif() +set(SIMBLE_FRAMEWORKS "-framework CoreBluetooth" "-framework Foundation") + +# The interposer core: the central swizzles, the shadow registry, and the loopback transport. +# No load constructor here; the dylib and the host harness add their own entry point, so both +# reuse this. The .m files use ARC. +add_library(interpose_core STATIC + src/hooks/central_hooks.m + src/registry/shadow.m + src/transport/client.c +) +target_include_directories(interpose_core PUBLIC include src/transport src/registry) +target_compile_options(interpose_core PRIVATE $<$:-fobjc-arc>) +target_link_libraries(interpose_core PUBLIC simble_protocol_c ${SIMBLE_FRAMEWORKS}) + if(SIMBLE_SIM_SLICE) + # The injectable dylib: the core plus the dyld load constructor. Each simulator platform gets + # its own name; ios keeps the canonical simble-interpose, so the helper and scripts that load + # it by that name need no change. add_library(simble_interpose MODULE src/entry.c) target_include_directories(simble_interpose PRIVATE include) set_target_properties( @@ -16,8 +33,29 @@ if(SIMBLE_SIM_SLICE) PREFIX "" OUTPUT_NAME "${SIMBLE_INTERPOSE_OUTPUT}" ) + target_link_libraries(simble_interpose PRIVATE interpose_core ${SIMBLE_FRAMEWORKS}) else() - add_executable(hook_smoke tests/hook_smoke.c src/entry.c) - target_include_directories(hook_smoke PRIVATE include) + # The swizzle smoke test: install and uninstall on the host, where the CB classes exist. + add_executable(hook_smoke tests/hook_smoke.c) + target_link_libraries(hook_smoke PRIVATE interpose_core ${SIMBLE_FRAMEWORKS}) add_test(NAME hook_smoke COMMAND hook_smoke) + + # The shadow registry unit test: mint, look up, fail closed. + add_executable(shadow_registry_test tests/shadow_registry_test.m) + target_compile_options(shadow_registry_test PRIVATE -fobjc-arc) + target_link_libraries(shadow_registry_test PRIVATE interpose_core ${SIMBLE_FRAMEWORKS}) + add_test(NAME shadow_registry COMMAND shadow_registry_test) + + # The passthrough invariant: a non-managed CBCentralManager method is unaffected by the + # installed swizzle. No helper, so it runs as a plain ctest. + add_executable(passthrough_test tests/passthrough_test.m) + target_compile_options(passthrough_test PRIVATE -fobjc-arc) + target_link_libraries(passthrough_test PRIVATE interpose_core ${SIMBLE_FRAMEWORKS}) + add_test(NAME passthrough COMMAND passthrough_test) + + # The transport round-trip: the client encodes a request and decodes a response and an event + # against an in-test loopback echo, no radio and no helper. + add_executable(client_roundtrip tests/client_roundtrip.c) + target_link_libraries(client_roundtrip PRIVATE interpose_core ${SIMBLE_FRAMEWORKS}) + add_test(NAME client_roundtrip COMMAND client_roundtrip) endif() diff --git a/packages/interpose/README.md b/packages/interpose/README.md index 5bf6c18..d487ef1 100644 --- a/packages/interpose/README.md +++ b/packages/interpose/README.md @@ -5,7 +5,11 @@ SPDX-FileCopyrightText: 2026 Nirapod Labs # interpose -The injected simulator dylib for CoreBluetooth routing. +The injected simulator dylib that routes a guest app's CoreBluetooth calls to the host. -The scaffold builds placeholder iOS and watchOS simulator slices. The -Objective-C runtime hooks and shadow object registries are not implemented yet. +It hooks the CoreBluetooth central surface with Objective-C runtime swizzling, holds a shadow +registry for the framework's opaque objects, and carries each operation to the helper over the +loopback transport, delivering host events back as the guest's delegate callbacks. Scan, connect, +read, write, notify, and RSSI are routed; service and characteristic discovery are not routed in the +central interposer, and the peripheral role is not implemented. The dylib is a simulator slice, +loaded only by a debug scheme and never by a shipped app (see `SECURITY.md` for the fence). diff --git a/packages/interpose/include/simble_interpose.h b/packages/interpose/include/simble_interpose.h index e6eb840..c3b489d 100644 --- a/packages/interpose/include/simble_interpose.h +++ b/packages/interpose/include/simble_interpose.h @@ -5,7 +5,16 @@ /** * @file simble_interpose.h - * @brief Public entry points for the simulator interposer scaffold. + * @brief The injected interposer's entry points: swizzle installation and the + * route-fire counters. + * + * @details + * The dylib's constructor calls ::simble_install_hooks at load when the environment + * is configured (SIMBLE_PORT and SIMBLE_TOKEN present) and stays inert otherwise. + * Counters a host harness reads to confirm the swizzles fired. + * + * @author Nirapod Labs + * @date 2026 */ #ifndef SIMBLE_INTERPOSE_H @@ -15,9 +24,50 @@ extern "C" { #endif +/** + * @defgroup simble_interpose Interposer entry points + * @brief Swizzle installation, uninstall, and the route counters. + * @{ + */ + /** Return the interposer scaffold version. */ int simble_interpose_version(void); +/** + * @brief Install the CoreBluetooth central swizzles. + * + * Exchanges the CBCentralManager and CBPeripheral method implementations for the routed + * ones. Idempotent: a second call is a no-op while the swizzles are installed. + * + * @return The number of selectors that could not be swizzled; 0 means every one is in place. + */ +int simble_install_hooks(void); + +/** + * @brief Remove the swizzles, restoring the original method implementations. + * + * Idempotent: a call while nothing is installed is a no-op. + */ +void simble_uninstall_hooks(void); + +/** How many times each routed central operation has fired since install. */ +typedef struct { + int scan_start; ///< scanForPeripheralsWithServices:options: calls routed to the helper. + int connect; ///< connectPeripheral:options: calls routed to the helper. + int read; ///< readValueForCharacteristic: calls routed to the helper. + int write; ///< writeValue:forCharacteristic:type: calls routed to the helper. + int set_notify; ///< setNotifyValue:forCharacteristic: calls routed to the helper. +} simble_hook_stats; + +/** + * @brief Read the route-fire counters. + * + * @return A copy of the counters at the time of the call. + */ +simble_hook_stats simble_get_hook_stats(void); + +/** @} */ + #ifdef __cplusplus } #endif diff --git a/packages/interpose/src/entry.c b/packages/interpose/src/entry.c index a8794f9..f9da5e4 100644 --- a/packages/interpose/src/entry.c +++ b/packages/interpose/src/entry.c @@ -3,8 +3,33 @@ * SPDX-FileCopyrightText: 2026 Nirapod Labs */ +/** + * @file entry.c + * @brief The dylib constructor: installs the central swizzles at load, inert without + * configuration. + * + * @details + * dyld runs the constructor when the dylib loads, before the app's main, so the + * CoreBluetooth central swizzles are in place before any CBCentralManager call. Loaded + * only via the debug scheme's dyld insert list; a release build bundles no dylib at all, + * which is the fence, not this gate. + * + * @author Nirapod Labs + * @date 2026 + */ + #include "simble_interpose.h" -int simble_interpose_version(void) { return 1; } +#include +#include -__attribute__((constructor)) static void simble_interpose_init(void) {} +__attribute__((constructor)) static void simble_interpose_init(void) { + // Inert without the dev-scheme env (SIMBLE_PORT, SIMBLE_TOKEN). Not the fence; a release build + // bundles no dylib. + if (!getenv("SIMBLE_PORT") || !getenv("SIMBLE_TOKEN")) + return; + int failures = simble_install_hooks(); + if (failures != 0) { + fprintf(stderr, "[simble] %d swizzle(s) failed to install\n", failures); + } +} diff --git a/packages/interpose/src/hooks/central_hooks.m b/packages/interpose/src/hooks/central_hooks.m new file mode 100644 index 0000000..cee60d9 --- /dev/null +++ b/packages/interpose/src/hooks/central_hooks.m @@ -0,0 +1,580 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * SPDX-FileCopyrightText: 2026 Nirapod Labs + */ + +/** + * @file central_hooks.m + * @brief The central swizzle set: CBCentralManager and CBPeripheral interception. + * + * @details + * The guest's central calls route to the host helper, and the host's events come back as + * the guest's own CBCentralManagerDelegate and CBPeripheralDelegate callbacks, dispatched + * on the queue the guest gave its manager. A call whose receiver the registry did not mint + * passes through to the saved original implementation, so a non-managed CoreBluetooth user + * is byte-for-byte unaffected. No key material, pairing secret, or bonding record crosses + * the interposer: only GATT operations and byte payloads do. + * + * @author Nirapod Labs + * @date 2026 + */ + +#import "../registry/shadow.h" +#import "../transport/client.h" +#import "simble_interpose.h" + +#import +#import +#import + +static simble_hook_stats g_stats = {0, 0, 0, 0, 0}; +static pthread_mutex_t g_install_lock = PTHREAD_MUTEX_INITIALIZER; +static int g_installed = 0; + +// The guest's bundle id and display name, for the HELLO the helper shows. Guest-reported, +// names the app, gates nothing. +static size_t copyAppId(char *buf, size_t cap) { + NSString *identifier = NSBundle.mainBundle.bundleIdentifier; + if (identifier && [identifier getCString:buf maxLength:cap encoding:NSUTF8StringEncoding]) { + return strlen(buf); + } + return 0; +} + +static size_t copyDisplayName(char *buf, size_t cap) { + NSDictionary *info = NSBundle.mainBundle.infoDictionary; + NSString *name = info[@"CFBundleDisplayName"] ?: info[@"CFBundleName"]; + if ([name isKindOfClass:NSString.class] && + [name getCString:buf maxLength:cap encoding:NSUTF8StringEncoding] && buf[0] != '\0') { + return strlen(buf); + } + return 0; +} + +// Announce identity once per process, best-effort. +static void announceIdentityOnce(void) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + char appId[256]; + size_t idLen = copyAppId(appId, sizeof(appId)); + char name[256]; + size_t nameLen = copyDisplayName(name, sizeof(name)); + simble_response resp; + simble_client_hello(SIMBLE_PROTOCOL_VERSION, idLen ? (const uint8_t *)appId : NULL, idLen, + nameLen ? (const uint8_t *)name : NULL, nameLen, &resp); + }); +} + +// Dispatch a block on the manager's queue, the main queue when it gave none. +static void dispatchOnManagerQueue(SimbleManagerEntry *entry, dispatch_block_t block) { + dispatch_queue_t queue = entry.queue ?: dispatch_get_main_queue(); + dispatch_async(queue, block); +} + +// --- The event stream: one reader thread per managed manager, started on first scan/connect --- + +// Translate one host event into the guest's delegate callbacks; runs on the reader thread, +// dispatches on the manager's queue. +static void deliverEvent(CBCentralManager *manager, const simble_event *event) { + SimbleManagerEntry *entry = simble_shadow_manager_entry(manager); + if (!entry) + return; + + switch (event->kind) { + case SIMBLE_EVT_DISCOVERED: { + CBPeripheral *peripheral = + simble_shadow_peripheral(manager, event->peripheral, event->peripheral_len); + NSNumber *rssi = @(event->rssi); + NSMutableDictionary *adv = [NSMutableDictionary dictionary]; + if (event->has_local_name) { + adv[CBAdvertisementDataLocalNameKey] = [NSString stringWithUTF8String:event->local_name]; + } + if (event->has_tx_power) { + adv[CBAdvertisementDataTxPowerLevelKey] = @(event->tx_power); + } + if (event->has_mfg_data) { + adv[CBAdvertisementDataManufacturerDataKey] = [NSData dataWithBytes:event->mfg_data + length:event->mfg_data_len]; + } + dispatchOnManagerQueue(entry, ^{ + id delegate = entry.delegate; + if ([delegate respondsToSelector: + @selector(centralManager:didDiscoverPeripheral:advertisementData:RSSI:)]) { + [delegate centralManager:manager + didDiscoverPeripheral:peripheral + advertisementData:adv + RSSI:rssi]; + } + }); + break; + } + case SIMBLE_EVT_DISCONNECTED: { + CBPeripheral *peripheral = + simble_shadow_peripheral(manager, event->peripheral, event->peripheral_len); + NSError *error = event->has_error_code ? [NSError errorWithDomain:CBErrorDomain + code:event->error_code + userInfo:nil] + : nil; + dispatchOnManagerQueue(entry, ^{ + id delegate = entry.delegate; + if ([delegate respondsToSelector:@selector(centralManager:didDisconnectPeripheral:error:)]) { + [delegate centralManager:manager didDisconnectPeripheral:peripheral error:error]; + } + }); + break; + } + case SIMBLE_EVT_CENTRAL_STATE_CHANGED: { + dispatchOnManagerQueue(entry, ^{ + id delegate = entry.delegate; + if ([delegate respondsToSelector:@selector(centralManagerDidUpdateState:)]) { + [delegate centralManagerDidUpdateState:manager]; + } + }); + break; + } + case SIMBLE_EVT_CHAR_VALUE: { + CBPeripheral *peripheral = + simble_shadow_peripheral(manager, event->peripheral, event->peripheral_len); + CBUUID *serviceUUID = [CBUUID UUIDWithString:[NSString stringWithUTF8String:event->service]]; + CBUUID *charUUID = + [CBUUID UUIDWithString:[NSString stringWithUTF8String:event->characteristic]]; + CBService *service = simble_shadow_service(peripheral, serviceUUID); + CBCharacteristic *characteristic = simble_shadow_characteristic(service, charUUID); + NSData *value = [NSData dataWithBytes:event->value length:event->value_len]; + dispatchOnManagerQueue(entry, ^{ + id delegate = peripheral.delegate; + // Attach the value to the characteristic stand-in before the didUpdate callback reads it. + objc_setAssociatedObject(characteristic, @selector(value), value, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + if ([delegate + respondsToSelector:@selector(peripheral:didUpdateValueForCharacteristic:error:)]) { + [delegate peripheral:peripheral didUpdateValueForCharacteristic:characteristic error:nil]; + } + }); + break; + } + default: + // A peripheral-role event is not delivered by the central interposer; the peripheral role + // is not implemented here. + break; + } +} + +// The reader loop: drain the event connection, deliver each event, exit when the connection closes. +static void runEventReader(CBCentralManager *manager) { + simble_conn conn; + if (simble_client_open(&conn) != SIMBLE_OK) + return; + for (;;) { + simble_event event; + if (simble_client_read_event(&conn, &event) != SIMBLE_OK) + break; + deliverEvent(manager, &event); + } + simble_client_close(&conn); +} + +// Start the event reader for a manager once, on the first scan or connect. +static void startEventReaderOnce(CBCentralManager *manager) { + static const void *kReaderStartedKey = &kReaderStartedKey; + @synchronized(manager) { + if (objc_getAssociatedObject(manager, kReaderStartedKey)) + return; + objc_setAssociatedObject(manager, kReaderStartedKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + CBCentralManager *captured = manager; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + runEventReader(captured); + }); +} + +// --- Swizzle plumbing --- + +// Swizzle an instance method on a class: exchange original and routed IMPs; record the pair for +// uninstall. +typedef struct { + Class cls; + SEL original; + SEL replacement; +} SwizzlePair; + +static SwizzlePair g_pairs[16]; +static size_t g_pair_count = 0; + +static int swizzle(Class cls, SEL original, SEL replacement) { + Method origMethod = class_getInstanceMethod(cls, original); + Method replMethod = class_getInstanceMethod(cls, replacement); + if (!origMethod || !replMethod) + return -1; + method_exchangeImplementations(origMethod, replMethod); + if (g_pair_count < sizeof(g_pairs) / sizeof(g_pairs[0])) { + g_pairs[g_pair_count++] = (SwizzlePair){cls, original, replacement}; + } + return 0; +} + +// --- CBCentralManager routed methods --- + +@interface CBCentralManager (SimbleCentral) +@end + +@implementation CBCentralManager (SimbleCentral) + +// Register the manager with its delegate and queue, then return the manager the original built. +- (instancetype)simble_initWithDelegate:(id)delegate + queue:(dispatch_queue_t)queue { + CBCentralManager *manager = [self simble_initWithDelegate:delegate queue:queue]; + if (manager) { + simble_shadow_register_manager(manager, delegate, queue); + announceIdentityOnce(); + SimbleManagerEntry *entry = simble_shadow_manager_entry(manager); + // Mirror CoreBluetooth's async first state update: centralManagerDidUpdateState: on the + // manager's queue. + dispatchOnManagerQueue(entry, ^{ + id d = entry.delegate; + if ([d respondsToSelector:@selector(centralManagerDidUpdateState:)]) { + [d centralManagerDidUpdateState:manager]; + } + }); + } + return manager; +} + +- (CBManagerState)simble_state { + if (!simble_shadow_is_managed_manager(self)) + return [self simble_state]; + simble_response resp; + if (simble_client_central_state(&resp) == SIMBLE_OK && resp.kind == SIMBLE_RESP_CENTRAL_STATE) { + return (CBManagerState)resp.manager_state; + } + return CBManagerStateUnknown; +} + +- (void)simble_scanForPeripheralsWithServices:(NSArray *)serviceUUIDs + options:(NSDictionary *)options { + if (!simble_shadow_is_managed_manager(self)) { + [self simble_scanForPeripheralsWithServices:serviceUUIDs options:options]; + return; + } + startEventReaderOnce(self); + size_t count = serviceUUIDs.count; + const char **uuids = count ? calloc(count, sizeof(char *)) : NULL; + size_t *lens = count ? calloc(count, sizeof(size_t)) : NULL; + NSMutableArray *held = [NSMutableArray arrayWithCapacity:count]; + for (size_t i = 0; i < count; i++) { + NSString *s = serviceUUIDs[i].UUIDString; + [held addObject:s]; + uuids[i] = s.UTF8String; + lens[i] = strlen(uuids[i]); + } + simble_response resp; + simble_client_scan_start(uuids, lens, count, &resp); + free(uuids); + free(lens); + g_stats.scan_start++; +} + +- (void)simble_stopScan { + if (!simble_shadow_is_managed_manager(self)) { + [self simble_stopScan]; + return; + } + simble_response resp; + simble_client_scan_stop(&resp); +} + +- (void)simble_connectPeripheral:(CBPeripheral *)peripheral + options:(NSDictionary *)options { + if (!simble_shadow_is_managed_manager(self) || !simble_shadow_is_managed_peripheral(peripheral)) { + [self simble_connectPeripheral:peripheral options:options]; + return; + } + startEventReaderOnce(self); + uint8_t pid[64]; + size_t idLen = 0; + if (!simble_shadow_peripheral_id(peripheral, pid, sizeof(pid), &idLen)) + return; + simble_response resp; + simble_status st = simble_client_connect(pid, idLen, &resp); + g_stats.connect++; + SimbleManagerEntry *entry = simble_shadow_manager_entry(self); + CBCentralManager *manager = self; + dispatchOnManagerQueue(entry, ^{ + id delegate = entry.delegate; + if (st == SIMBLE_OK && resp.kind == SIMBLE_RESP_PERIPHERAL) { + if ([delegate respondsToSelector:@selector(centralManager:didConnectPeripheral:)]) { + [delegate centralManager:manager didConnectPeripheral:peripheral]; + } + } else if ([delegate + respondsToSelector:@selector( + centralManager:didFailToConnectPeripheral:error:)]) { + NSError *error = [NSError errorWithDomain:CBErrorDomain code:resp.error_code userInfo:nil]; + [delegate centralManager:manager didFailToConnectPeripheral:peripheral error:error]; + } + }); +} + +- (void)simble_cancelPeripheralConnection:(CBPeripheral *)peripheral { + if (!simble_shadow_is_managed_manager(self) || !simble_shadow_is_managed_peripheral(peripheral)) { + [self simble_cancelPeripheralConnection:peripheral]; + return; + } + uint8_t pid[64]; + size_t idLen = 0; + if (!simble_shadow_peripheral_id(peripheral, pid, sizeof(pid), &idLen)) + return; + simble_response resp; + simble_status st = simble_client_disconnect(pid, idLen, &resp); + SimbleManagerEntry *entry = simble_shadow_manager_entry(self); + CBCentralManager *manager = self; + dispatchOnManagerQueue(entry, ^{ + id delegate = entry.delegate; + NSError *error = (st == SIMBLE_OK && resp.kind == SIMBLE_RESP_PERIPHERAL) + ? nil + : [NSError errorWithDomain:CBErrorDomain + code:resp.error_code + userInfo:nil]; + if ([delegate respondsToSelector:@selector(centralManager:didDisconnectPeripheral:error:)]) { + [delegate centralManager:manager didDisconnectPeripheral:peripheral error:error]; + } + }); +} + +@end + +// --- CBPeripheral routed methods --- + +@interface CBPeripheral (SimbleCentral) +@end + +@implementation CBPeripheral (SimbleCentral) + +// Resolve this peripheral's host id, or return NO when it is not a minted stand-in. +static BOOL peripheralRouteId(CBPeripheral *peripheral, uint8_t *out, size_t cap, size_t *outLen) { + return simble_shadow_peripheral_id(peripheral, out, cap, outLen); +} + +- (void)simble_discoverServices:(NSArray *)serviceUUIDs { + if (!simble_shadow_is_managed_peripheral(self)) { + [self simble_discoverServices:serviceUUIDs]; + return; + } + // Service discovery is not routed in the central interposer; a routed discoverServices is a + // no-op. The routed read, write, and notify paths address a characteristic by its UUID. +} + +- (void)simble_discoverCharacteristics:(NSArray *)characteristicUUIDs + forService:(CBService *)service { + if (!simble_shadow_is_managed_peripheral(self)) { + [self simble_discoverCharacteristics:characteristicUUIDs forService:service]; + return; + } + // Characteristic discovery is likewise not routed; a routed discoverCharacteristics is a no-op. +} + +- (void)simble_readValueForCharacteristic:(CBCharacteristic *)characteristic { + if (!simble_shadow_is_managed_peripheral(self)) { + [self simble_readValueForCharacteristic:characteristic]; + return; + } + uint8_t pid[64]; + size_t idLen = 0; + CBUUID *serviceUUID = nil; + CBUUID *charUUID = nil; + if (!simble_shadow_resolve_characteristic(characteristic, pid, sizeof(pid), &idLen, &serviceUUID, + &charUUID)) { + return; + } + const char *service = serviceUUID.UUIDString.UTF8String; + const char *chr = charUUID.UUIDString.UTF8String; + simble_response resp; + simble_status st = simble_client_read_characteristic(pid, idLen, service, strlen(service), chr, + strlen(chr), &resp); + g_stats.read++; + CBPeripheral *peripheral = self; + SimbleManagerEntry *entry = simble_shadow_manager_entry(simble_shadow_owner(self)); + dispatchOnManagerQueue(entry, ^{ + id delegate = peripheral.delegate; + NSError *error = nil; + if (st == SIMBLE_OK && resp.kind == SIMBLE_RESP_CHAR_VALUE) { + NSData *value = [NSData dataWithBytes:resp.value length:resp.value_len]; + objc_setAssociatedObject(characteristic, @selector(value), value, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } else { + error = [NSError errorWithDomain:CBErrorDomain code:resp.error_code userInfo:nil]; + } + if ([delegate + respondsToSelector:@selector(peripheral:didUpdateValueForCharacteristic:error:)]) { + [delegate peripheral:peripheral didUpdateValueForCharacteristic:characteristic error:error]; + } + }); +} + +- (void)simble_writeValue:(NSData *)data + forCharacteristic:(CBCharacteristic *)characteristic + type:(CBCharacteristicWriteType)type { + if (!simble_shadow_is_managed_peripheral(self)) { + [self simble_writeValue:data forCharacteristic:characteristic type:type]; + return; + } + uint8_t pid[64]; + size_t idLen = 0; + CBUUID *serviceUUID = nil; + CBUUID *charUUID = nil; + if (!simble_shadow_resolve_characteristic(characteristic, pid, sizeof(pid), &idLen, &serviceUUID, + &charUUID)) { + return; + } + const char *service = serviceUUID.UUIDString.UTF8String; + const char *chr = charUUID.UUIDString.UTF8String; + simble_write_type writeType = (type == CBCharacteristicWriteWithoutResponse) + ? SIMBLE_WRITE_WITHOUT_RESPONSE + : SIMBLE_WRITE_WITH_RESPONSE; + simble_response resp; + simble_status st = + simble_client_write_characteristic(pid, idLen, service, strlen(service), chr, strlen(chr), + data.bytes, data.length, writeType, &resp); + g_stats.write++; + // A withResponse write reports completion through didWriteValueForCharacteristic:; a + // withoutResponse write reports nothing. + if (type == CBCharacteristicWriteWithResponse) { + CBPeripheral *peripheral = self; + SimbleManagerEntry *entry = simble_shadow_manager_entry(simble_shadow_owner(self)); + dispatchOnManagerQueue(entry, ^{ + id delegate = peripheral.delegate; + NSError *error = (st == SIMBLE_OK && resp.kind == SIMBLE_RESP_CONFIRMED) + ? nil + : [NSError errorWithDomain:CBErrorDomain + code:resp.error_code + userInfo:nil]; + if ([delegate + respondsToSelector:@selector(peripheral:didWriteValueForCharacteristic:error:)]) { + [delegate peripheral:peripheral didWriteValueForCharacteristic:characteristic error:error]; + } + }); + } +} + +- (void)simble_setNotifyValue:(BOOL)enabled forCharacteristic:(CBCharacteristic *)characteristic { + if (!simble_shadow_is_managed_peripheral(self)) { + [self simble_setNotifyValue:enabled forCharacteristic:characteristic]; + return; + } + uint8_t pid[64]; + size_t idLen = 0; + CBUUID *serviceUUID = nil; + CBUUID *charUUID = nil; + if (!simble_shadow_resolve_characteristic(characteristic, pid, sizeof(pid), &idLen, &serviceUUID, + &charUUID)) { + return; + } + const char *service = serviceUUID.UUIDString.UTF8String; + const char *chr = charUUID.UUIDString.UTF8String; + simble_response resp; + simble_status st = simble_client_set_notify(pid, idLen, service, strlen(service), chr, + strlen(chr), enabled ? 1 : 0, &resp); + g_stats.set_notify++; + CBPeripheral *peripheral = self; + SimbleManagerEntry *entry = simble_shadow_manager_entry(simble_shadow_owner(self)); + dispatchOnManagerQueue(entry, ^{ + id delegate = peripheral.delegate; + NSError *error = (st == SIMBLE_OK && resp.kind == SIMBLE_RESP_NOTIFY_STATE) + ? nil + : [NSError errorWithDomain:CBErrorDomain + code:resp.error_code + userInfo:nil]; + if ([delegate respondsToSelector: + @selector(peripheral:didUpdateNotificationStateForCharacteristic:error:)]) { + [delegate peripheral:peripheral + didUpdateNotificationStateForCharacteristic:characteristic + error:error]; + } + }); +} + +- (void)simble_readRSSI { + if (!simble_shadow_is_managed_peripheral(self)) { + [self simble_readRSSI]; + return; + } + uint8_t pid[64]; + size_t idLen = 0; + if (!peripheralRouteId(self, pid, sizeof(pid), &idLen)) + return; + simble_response resp; + simble_status st = simble_client_read_rssi(pid, idLen, &resp); + CBPeripheral *peripheral = self; + SimbleManagerEntry *entry = simble_shadow_manager_entry(simble_shadow_owner(self)); + dispatchOnManagerQueue(entry, ^{ + id delegate = peripheral.delegate; + if (st == SIMBLE_OK && resp.kind == SIMBLE_RESP_RSSI) { + if ([delegate respondsToSelector:@selector(peripheral:didReadRSSI:error:)]) { + [delegate peripheral:peripheral didReadRSSI:@(resp.rssi) error:nil]; + } + } else if ([delegate respondsToSelector:@selector(peripheral:didReadRSSI:error:)]) { + NSError *error = [NSError errorWithDomain:CBErrorDomain code:resp.error_code userInfo:nil]; + [delegate peripheral:peripheral didReadRSSI:@0 error:error]; + } + }); +} + +@end + +int simble_install_hooks(void) { + pthread_mutex_lock(&g_install_lock); + if (g_installed) { + pthread_mutex_unlock(&g_install_lock); + return 0; + } + int failures = 0; + Class managerClass = [CBCentralManager class]; + failures += swizzle(managerClass, @selector(initWithDelegate:queue:), + @selector(simble_initWithDelegate:queue:)); + failures += swizzle(managerClass, @selector(state), @selector(simble_state)); + failures += swizzle(managerClass, @selector(scanForPeripheralsWithServices:options:), + @selector(simble_scanForPeripheralsWithServices:options:)); + failures += swizzle(managerClass, @selector(stopScan), @selector(simble_stopScan)); + failures += swizzle(managerClass, @selector(connectPeripheral:options:), + @selector(simble_connectPeripheral:options:)); + failures += swizzle(managerClass, @selector(cancelPeripheralConnection:), + @selector(simble_cancelPeripheralConnection:)); + + Class peripheralClass = [CBPeripheral class]; + failures += + swizzle(peripheralClass, @selector(discoverServices:), @selector(simble_discoverServices:)); + failures += swizzle(peripheralClass, @selector(discoverCharacteristics:forService:), + @selector(simble_discoverCharacteristics:forService:)); + failures += swizzle(peripheralClass, @selector(readValueForCharacteristic:), + @selector(simble_readValueForCharacteristic:)); + failures += swizzle(peripheralClass, @selector(writeValue:forCharacteristic:type:), + @selector(simble_writeValue:forCharacteristic:type:)); + failures += swizzle(peripheralClass, @selector(setNotifyValue:forCharacteristic:), + @selector(simble_setNotifyValue:forCharacteristic:)); + failures += swizzle(peripheralClass, @selector(readRSSI), @selector(simble_readRSSI)); + + g_installed = (failures == 0); + pthread_mutex_unlock(&g_install_lock); + return failures; +} + +void simble_uninstall_hooks(void) { + pthread_mutex_lock(&g_install_lock); + if (!g_installed) { + pthread_mutex_unlock(&g_install_lock); + return; + } + // Exchange each pair back to restore the originals. + for (size_t i = g_pair_count; i-- > 0;) { + Method orig = class_getInstanceMethod(g_pairs[i].cls, g_pairs[i].original); + Method repl = class_getInstanceMethod(g_pairs[i].cls, g_pairs[i].replacement); + if (orig && repl) + method_exchangeImplementations(orig, repl); + } + g_pair_count = 0; + g_installed = 0; + pthread_mutex_unlock(&g_install_lock); +} + +simble_hook_stats simble_get_hook_stats(void) { return g_stats; } + +int simble_interpose_version(void) { return 1; } diff --git a/packages/interpose/src/registry/shadow.h b/packages/interpose/src/registry/shadow.h new file mode 100644 index 0000000..70cc150 --- /dev/null +++ b/packages/interpose/src/registry/shadow.h @@ -0,0 +1,167 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * SPDX-FileCopyrightText: 2026 Nirapod Labs + */ + +/** + * @file shadow.h + * @brief The shadow registry: the managers, peripherals, services, and characteristics + * the interposer mints to stand in for the host's CoreBluetooth objects. + * + * @details + * CoreBluetooth's CBPeripheral, CBService, and CBCharacteristic are opaque and cannot + * be constructed by an app, so the interposer mints stand-ins keyed by the host + * identifier and hands them to the guest's delegate. The registry is the one map from + * a stand-in back to its host identity. It fails closed: a routing path that receives + * an object the registry did not mint treats it as not managed and passes it through. + * + * A CBCentralManager the guest creates while the interposer is active is registered + * here too, with the delegate and dispatch queue it was given, so a host event becomes + * a delegate callback dispatched on that queue. + * + * All functions are safe to call from any thread; the tables are guarded by one + * internal lock. + * + * @author Nirapod Labs + * @date 2026 + */ + +#ifndef SIMBLE_SHADOW_H +#define SIMBLE_SHADOW_H + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * @defgroup simble_shadow Shadow registry + * @brief Minting and lookup of the stand-in CoreBluetooth objects. + * @{ + */ + +/// A registered central manager: the delegate to call back and the queue to call it on. +@interface SimbleManagerEntry : NSObject +/// The delegate the guest passed to initWithDelegate:queue:, weakly held as CoreBluetooth does. +@property(nonatomic, weak, nullable) id delegate; +/// The dispatch queue the guest gave its manager; nil means the main queue, as CoreBluetooth does. +@property(nonatomic, strong, nullable) dispatch_queue_t queue; +@end + +/** + * @brief Register a CBCentralManager the guest created while the interposer is active. + * + * @param[in] manager The manager instance. + * @param[in] delegate The delegate it was created with, or nil. + * @param[in] queue The dispatch queue it was created with, or nil for the main queue. + */ +void simble_shadow_register_manager(CBCentralManager *manager, + id _Nullable delegate, + dispatch_queue_t _Nullable queue); + +/** + * @brief Whether a CBCentralManager is one the interposer manages. + * + * @param[in] manager The manager to test. + * @return YES if registered, NO otherwise. + */ +BOOL simble_shadow_is_managed_manager(CBCentralManager *manager); + +/** + * @brief The registration for a managed manager, or nil if it is not managed. + * + * @param[in] manager The manager to look up. + * @return The entry, or nil. + */ +SimbleManagerEntry *_Nullable simble_shadow_manager_entry(CBCentralManager *manager); + +/** + * @brief Mint or return the stand-in peripheral for a host identifier under a manager. + * + * The first call for an identifier mints a CBPeripheral stand-in and records it; a later + * call for the same identifier returns the same stand-in. + * + * @param[in] manager The owning managed manager. + * @param[in] peripheralId The host CBPeripheral.identifier bytes. + * @param[in] peripheralLen Length of @p peripheralId. + * @return The stand-in peripheral, or nil if the identifier was empty or @p manager is not managed. + */ +CBPeripheral *_Nullable simble_shadow_peripheral(CBCentralManager *manager, + const uint8_t *peripheralId, size_t peripheralLen); + +/** + * @brief Whether a CBPeripheral is one the interposer minted. + * + * @param[in] peripheral The peripheral to test. + * @return YES if minted here, NO otherwise. + */ +BOOL simble_shadow_is_managed_peripheral(CBPeripheral *peripheral); + +/** + * @brief Copy the host identifier bytes of a minted peripheral. + * + * @param[in] peripheral The minted peripheral. + * @param[out] out Buffer the identifier is copied into. + * @param[in] cap Capacity of @p out. + * @param[out] outLen Bytes written to @p out. + * @return YES on a hit, NO if @p peripheral was not minted here or @p cap is too small. + */ +BOOL simble_shadow_peripheral_id(CBPeripheral *peripheral, uint8_t *out, size_t cap, + size_t *outLen); + +/** + * @brief The managed manager that owns a minted peripheral, or nil. + * + * @param[in] peripheral The minted peripheral. + * @return The owning manager, or nil if @p peripheral was not minted here. + */ +CBCentralManager *_Nullable simble_shadow_owner(CBPeripheral *peripheral); + +/** + * @brief Mint or return the stand-in service for a UUID on a minted peripheral. + * + * @param[in] peripheral The minted peripheral. + * @param[in] serviceUUID The service CBUUID. + * @return The stand-in service, or nil if @p peripheral was not minted here. + */ +CBService *_Nullable simble_shadow_service(CBPeripheral *peripheral, CBUUID *serviceUUID); + +/** + * @brief Mint or return the stand-in characteristic for a UUID on a minted service. + * + * @param[in] service The minted service. + * @param[in] characteristicUUID The characteristic CBUUID. + * @return The stand-in characteristic, or nil if @p service was not minted here. + */ +CBCharacteristic *_Nullable simble_shadow_characteristic(CBService *service, + CBUUID *characteristicUUID); + +/** + * @brief Resolve a minted characteristic to its peripheral id, service UUID, and characteristic + * UUID, so an operation on it can be routed by host identity. + * + * @param[in] characteristic The minted characteristic. + * @param[out] peripheralOut Buffer the peripheral id is copied into. + * @param[in] peripheralCap Capacity of @p peripheralOut. + * @param[out] peripheralLen Bytes written to @p peripheralOut. + * @param[out] serviceUUID Set to the service CBUUID. + * @param[out] characteristicUUID Set to the characteristic CBUUID. + * @return YES on a hit, NO if @p characteristic was not minted here. + */ +BOOL simble_shadow_resolve_characteristic(CBCharacteristic *characteristic, uint8_t *peripheralOut, + size_t peripheralCap, size_t *peripheralLen, + CBUUID *_Nullable *_Nonnull serviceUUID, + CBUUID *_Nullable *_Nonnull characteristicUUID); + +/** + * @brief Drop every registration. + * + * Returns the registry to its empty state. Used by tests between cases. + */ +void simble_shadow_reset(void); + +/** @} */ + +NS_ASSUME_NONNULL_END + +#endif diff --git a/packages/interpose/src/registry/shadow.m b/packages/interpose/src/registry/shadow.m new file mode 100644 index 0000000..b10b1c7 --- /dev/null +++ b/packages/interpose/src/registry/shadow.m @@ -0,0 +1,281 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * SPDX-FileCopyrightText: 2026 Nirapod Labs + */ + +/** + * @file shadow.m + * @brief Shadow registry implementation: stand-in CoreBluetooth objects behind one lock. + * + * @details + * CBPeripheral, CBService, and CBCharacteristic cannot be constructed through their + * public API, so stand-ins are runtime subclasses of the CB classes with a no-op dealloc + * (see shadowDealloc). The interposer never reads CoreBluetooth's private ivars: a + * stand-in's identity is the associated state and the registry's own tables, so an object + * the registry did not mint resolves to nothing and is treated as not managed. + * + * @see shadow.h for the API documentation. + * + * @author Nirapod Labs + * @date 2026 + */ + +#import "shadow.h" + +#import + +@implementation SimbleManagerEntry +@end + +// Identity attached to a minted peripheral, service, or characteristic. A minted object +// carries one of these as associated state; absence of it is what "not managed" means. +@interface SimbleShadowMeta : NSObject +@property(nonatomic, strong) NSData *peripheralId; // peripheral id (peripheral, service, char) +@property(nonatomic, strong, nullable) CBUUID *serviceUUID; // service and characteristic +@property(nonatomic, strong, nullable) CBUUID *characteristicUUID; // characteristic only +@property(nonatomic, weak, nullable) CBCentralManager *owner; // owning manager (peripheral) +@property(nonatomic, weak, nullable) CBPeripheral *peripheral; // owning peripheral (service) +@end + +@implementation SimbleShadowMeta +@end + +static const void *kShadowMetaKey = &kShadowMetaKey; + +// One lock guards all of the tables below. +static NSLock *g_lock; + +// Registered managers, keyed by the manager pointer (NSValue of a non-retained pointer); the +// value is the SimbleManagerEntry. Strong on the entry, weak on the manager via the entry's +// delegate property, mirroring CoreBluetooth's weak delegate. +static NSMutableDictionary *g_managers; + +// Minted peripherals, keyed by ":", so one stand-in is +// returned per identifier per manager. Retained for the process lifetime; the pointer is never +// freed or reused. +static NSMutableDictionary *g_peripherals; +// Minted services, keyed by ":". +static NSMutableDictionary *g_services; +// Minted characteristics, keyed by ":". +static NSMutableDictionary *g_characteristics; +// The set of minted object pointers, the membership authority for the fail-closed check. +static NSMutableSet *g_minted; + +// A no-op dealloc, installed on each stand-in subclass. A bare instance of a CoreBluetooth class +// was never run through CoreBluetooth's own initializer, so the framework's dealloc (which tears +// down KVO registrations the initializer made) must not run on it; this leaves the object's own +// memory to the runtime to free after dealloc returns. +static void shadowDealloc(id self, SEL _cmd) { + (void)self; + (void)_cmd; +} + +// Build a stand-in subclass of a CoreBluetooth class with the no-op dealloc, once per class. +static Class makeShadowSubclass(Class base, const char *name) { + Class cls = objc_allocateClassPair(base, name, 0); + class_addMethod(cls, sel_registerName("dealloc"), (IMP)shadowDealloc, "v@:"); + objc_registerClassPair(cls); + return cls; +} + +static Class g_peripheralShadowClass; +static Class g_serviceShadowClass; +static Class g_characteristicShadowClass; + +__attribute__((constructor)) static void simble_shadow_init(void) { + g_lock = [NSLock new]; + g_managers = [NSMutableDictionary new]; + g_peripherals = [NSMutableDictionary new]; + g_services = [NSMutableDictionary new]; + g_characteristics = [NSMutableDictionary new]; + g_minted = [NSMutableSet new]; + g_peripheralShadowClass = makeShadowSubclass([CBPeripheral class], "SimbleShadowPeripheral"); + g_serviceShadowClass = makeShadowSubclass([CBService class], "SimbleShadowService"); + g_characteristicShadowClass = + makeShadowSubclass([CBCharacteristic class], "SimbleShadowCharacteristic"); +} + +static NSValue *ptr(id object) { return [NSValue valueWithPointer:(__bridge const void *)object]; } + +static NSString *hexOf(const uint8_t *bytes, size_t len) { + NSMutableString *s = [NSMutableString stringWithCapacity:len * 2]; + for (size_t i = 0; i < len; i++) + [s appendFormat:@"%02x", bytes[i]]; + return s; +} + +// Create a stand-in instance of a shadow subclass. The instance carries no CoreBluetooth state; +// the interposer reads only the associated identity it attaches. +static id mintInstance(Class cls) { return class_createInstance(cls, 0); } + +static SimbleShadowMeta *metaOf(id object) { + return object ? objc_getAssociatedObject(object, kShadowMetaKey) : nil; +} + +void simble_shadow_register_manager(CBCentralManager *manager, + id delegate, dispatch_queue_t queue) { + if (!manager) + return; + SimbleManagerEntry *entry = [SimbleManagerEntry new]; + entry.delegate = delegate; + entry.queue = queue; + [g_lock lock]; + g_managers[ptr(manager)] = entry; + [g_lock unlock]; +} + +BOOL simble_shadow_is_managed_manager(CBCentralManager *manager) { + if (!manager) + return NO; + [g_lock lock]; + BOOL found = g_managers[ptr(manager)] != nil; + [g_lock unlock]; + return found; +} + +SimbleManagerEntry *simble_shadow_manager_entry(CBCentralManager *manager) { + if (!manager) + return nil; + [g_lock lock]; + SimbleManagerEntry *entry = g_managers[ptr(manager)]; + [g_lock unlock]; + return entry; +} + +CBPeripheral *simble_shadow_peripheral(CBCentralManager *manager, const uint8_t *peripheralId, + size_t peripheralLen) { + if (!manager || !peripheralId || peripheralLen == 0) + return nil; + // Fail closed: only a managed manager mints peripherals. + if (!simble_shadow_is_managed_manager(manager)) + return nil; + NSString *key = + [NSString stringWithFormat:@"%p:%@", (void *)manager, hexOf(peripheralId, peripheralLen)]; + [g_lock lock]; + CBPeripheral *existing = g_peripherals[key]; + if (existing) { + [g_lock unlock]; + return existing; + } + CBPeripheral *minted = mintInstance(g_peripheralShadowClass); + SimbleShadowMeta *meta = [SimbleShadowMeta new]; + meta.peripheralId = [NSData dataWithBytes:peripheralId length:peripheralLen]; + meta.owner = manager; + objc_setAssociatedObject(minted, kShadowMetaKey, meta, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + g_peripherals[key] = minted; + [g_minted addObject:ptr(minted)]; + [g_lock unlock]; + return minted; +} + +BOOL simble_shadow_is_managed_peripheral(CBPeripheral *peripheral) { + if (!peripheral) + return NO; + [g_lock lock]; + BOOL found = [g_minted containsObject:ptr(peripheral)]; + [g_lock unlock]; + return found; +} + +BOOL simble_shadow_peripheral_id(CBPeripheral *peripheral, uint8_t *out, size_t cap, + size_t *outLen) { + if (!simble_shadow_is_managed_peripheral(peripheral)) + return NO; + SimbleShadowMeta *meta = metaOf(peripheral); + NSData *pidData = meta.peripheralId; + if (!pidData || (size_t)pidData.length > cap) + return NO; + memcpy(out, pidData.bytes, pidData.length); + *outLen = pidData.length; + return YES; +} + +CBCentralManager *simble_shadow_owner(CBPeripheral *peripheral) { + if (!simble_shadow_is_managed_peripheral(peripheral)) + return nil; + return metaOf(peripheral).owner; +} + +CBService *simble_shadow_service(CBPeripheral *peripheral, CBUUID *serviceUUID) { + if (!simble_shadow_is_managed_peripheral(peripheral) || !serviceUUID) + return nil; + NSString *key = [NSString stringWithFormat:@"%p:%@", (void *)peripheral, serviceUUID.UUIDString]; + [g_lock lock]; + CBService *existing = g_services[key]; + if (existing) { + [g_lock unlock]; + return existing; + } + SimbleShadowMeta *pmeta = metaOf(peripheral); + CBService *minted = mintInstance(g_serviceShadowClass); + SimbleShadowMeta *meta = [SimbleShadowMeta new]; + meta.peripheralId = pmeta.peripheralId; + meta.serviceUUID = serviceUUID; + meta.peripheral = peripheral; + objc_setAssociatedObject(minted, kShadowMetaKey, meta, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + g_services[key] = minted; + [g_minted addObject:ptr(minted)]; + [g_lock unlock]; + return minted; +} + +CBCharacteristic *simble_shadow_characteristic(CBService *service, CBUUID *characteristicUUID) { + if (!service || !characteristicUUID) + return nil; + [g_lock lock]; + BOOL serviceMinted = [g_minted containsObject:ptr(service)]; + [g_lock unlock]; + if (!serviceMinted) + return nil; + NSString *key = + [NSString stringWithFormat:@"%p:%@", (void *)service, characteristicUUID.UUIDString]; + [g_lock lock]; + CBCharacteristic *existing = g_characteristics[key]; + if (existing) { + [g_lock unlock]; + return existing; + } + SimbleShadowMeta *smeta = metaOf(service); + CBCharacteristic *minted = mintInstance(g_characteristicShadowClass); + SimbleShadowMeta *meta = [SimbleShadowMeta new]; + meta.peripheralId = smeta.peripheralId; + meta.serviceUUID = smeta.serviceUUID; + meta.characteristicUUID = characteristicUUID; + objc_setAssociatedObject(minted, kShadowMetaKey, meta, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + g_characteristics[key] = minted; + [g_minted addObject:ptr(minted)]; + [g_lock unlock]; + return minted; +} + +BOOL simble_shadow_resolve_characteristic(CBCharacteristic *characteristic, uint8_t *peripheralOut, + size_t peripheralCap, size_t *peripheralLen, + CBUUID *_Nullable *_Nonnull serviceUUID, + CBUUID *_Nullable *_Nonnull characteristicUUID) { + if (!characteristic) + return NO; + [g_lock lock]; + BOOL minted = [g_minted containsObject:ptr(characteristic)]; + [g_lock unlock]; + if (!minted) + return NO; + SimbleShadowMeta *meta = metaOf(characteristic); + NSData *pidData = meta.peripheralId; + if (!pidData || (size_t)pidData.length > peripheralCap) + return NO; + memcpy(peripheralOut, pidData.bytes, pidData.length); + *peripheralLen = pidData.length; + *serviceUUID = meta.serviceUUID; + *characteristicUUID = meta.characteristicUUID; + return YES; +} + +void simble_shadow_reset(void) { + [g_lock lock]; + [g_managers removeAllObjects]; + [g_peripherals removeAllObjects]; + [g_services removeAllObjects]; + [g_characteristics removeAllObjects]; + [g_minted removeAllObjects]; + [g_lock unlock]; +} diff --git a/packages/interpose/src/transport/client.c b/packages/interpose/src/transport/client.c new file mode 100644 index 0000000..15c9238 --- /dev/null +++ b/packages/interpose/src/transport/client.c @@ -0,0 +1,296 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * SPDX-FileCopyrightText: 2026 Nirapod Labs + */ + +/** + * @file client.c + * @brief Loopback client implementation: connect, frame, send, read, decode. + * + * @see client.h for the API documentation. + * + * @author Nirapod Labs + * @date 2026 + */ + +#include "client.h" + +#include +#include +#include +#include +#include +#include + +static int connect_helper(void) { + const char *env = getenv("SIMBLE_PORT"); + if (!env) + return -1; + char *end = NULL; + long port = strtol(env, &end, 10); + if (end == env || *end != '\0' || port <= 0 || port > 65535) + return -1; + + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) + return -1; + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons((uint16_t)port); + addr.sin_addr.s_addr = inet_addr("127.0.0.1"); + + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + close(fd); + return -1; + } + return fd; +} + +static int write_all(int fd, const uint8_t *buf, size_t len) { + size_t written = 0; + while (written < len) { + ssize_t n = send(fd, buf + written, len - written, 0); + if (n <= 0) + return -1; + written += (size_t)n; + } + return 0; +} + +static int read_all(int fd, uint8_t *buf, size_t len) { + size_t read_count = 0; + while (read_count < len) { + ssize_t n = recv(fd, buf + read_count, len - read_count, 0); + if (n <= 0) + return -1; + read_count += (size_t)n; + } + return 0; +} + +static int hex_nibble(char c) { + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + return -1; +} + +// Decode the 32-byte capability token from SIMBLE_TOKEN (64 hex chars). Returns 0 on +// success, -1 when the variable is missing or malformed. +static int read_token(uint8_t out[32]) { + const char *hex = getenv("SIMBLE_TOKEN"); + if (!hex || strlen(hex) != 64) + return -1; + for (size_t i = 0; i < 32; i++) { + int hi = hex_nibble(hex[i * 2]); + int lo = hex_nibble(hex[(i * 2) + 1]); + if (hi < 0 || lo < 0) + return -1; + out[i] = (uint8_t)((hi << 4) | lo); + } + return 0; +} + +// Read one length-prefixed frame's payload into a caller buffer. +static simble_status read_frame(int fd, uint8_t *resp, size_t resp_cap, size_t *resp_len) { + uint8_t prefix[4]; + if (read_all(fd, prefix, 4) != 0) + return SIMBLE_ERR_TRUNCATED; + long payload_len = simble_payload_length(prefix); + if (payload_len < 0) + return SIMBLE_ERR_MALFORMED; + if ((size_t)payload_len > resp_cap) + return SIMBLE_ERR_BUFFER; + if (read_all(fd, resp, (size_t)payload_len) != 0) + return SIMBLE_ERR_TRUNCATED; + *resp_len = (size_t)payload_len; + return SIMBLE_OK; +} + +// Send one framed request and read one framed reply payload into a caller buffer, undecoded. +static simble_status do_request_raw(const uint8_t *payload, int payload_len, uint8_t *resp, + size_t resp_cap, size_t *resp_len) { + if (payload_len < 0) + return SIMBLE_ERR_BUFFER; + + int fd = connect_helper(); + if (fd < 0) + return SIMBLE_ERR_TRUNCATED; + + uint8_t frame[8192]; + int frame_len = simble_frame(payload, (size_t)payload_len, frame, sizeof(frame)); + if (frame_len < 0 || write_all(fd, frame, (size_t)frame_len) != 0) { + close(fd); + return SIMBLE_ERR_BUFFER; + } + simble_status st = read_frame(fd, resp, resp_cap, resp_len); + close(fd); + return st; +} + +static simble_status do_request(const uint8_t *payload, int payload_len, simble_response *out) { + uint8_t response[4096]; + size_t response_len = 0; + simble_status st = + do_request_raw(payload, payload_len, response, sizeof(response), &response_len); + if (st != SIMBLE_OK) + return st; + return simble_decode_response(response, response_len, out); +} + +simble_status simble_client_hello(uint64_t version, const uint8_t *app_id, size_t app_id_len, + const uint8_t *display_name, size_t display_name_len, + simble_response *out) { + uint8_t token[32]; + if (read_token(token) != 0) + return SIMBLE_ERR_TRUNCATED; + uint8_t payload[256]; + int n = simble_encode_hello(token, sizeof(token), version, app_id, app_id_len, display_name, + display_name_len, payload, sizeof(payload)); + return do_request(payload, n, out); +} + +simble_status simble_client_central_state(simble_response *out) { + uint8_t token[32]; + if (read_token(token) != 0) + return SIMBLE_ERR_TRUNCATED; + uint8_t payload[64]; + // CENTRAL_STATE is op 2, a token-only command. + int n = simble_encode_command(2, token, sizeof(token), payload, sizeof(payload)); + return do_request(payload, n, out); +} + +simble_status simble_client_scan_start(const char *const *uuids, const size_t *uuid_lens, + size_t uuid_count, simble_response *out) { + uint8_t token[32]; + if (read_token(token) != 0) + return SIMBLE_ERR_TRUNCATED; + uint8_t payload[1024]; + int n = simble_encode_scan_start(token, sizeof(token), uuids, uuid_lens, uuid_count, payload, + sizeof(payload)); + return do_request(payload, n, out); +} + +simble_status simble_client_scan_stop(simble_response *out) { + uint8_t token[32]; + if (read_token(token) != 0) + return SIMBLE_ERR_TRUNCATED; + uint8_t payload[64]; + // SCAN_STOP is op 4, a token-only command. + int n = simble_encode_command(4, token, sizeof(token), payload, sizeof(payload)); + return do_request(payload, n, out); +} + +simble_status simble_client_connect(const uint8_t *peripheral_id, size_t peripheral_len, + simble_response *out) { + uint8_t token[32]; + if (read_token(token) != 0) + return SIMBLE_ERR_TRUNCATED; + uint8_t payload[256]; + // CONNECT is op 5. + int n = simble_encode_peripheral_command(5, token, sizeof(token), peripheral_id, peripheral_len, + payload, sizeof(payload)); + return do_request(payload, n, out); +} + +simble_status simble_client_disconnect(const uint8_t *peripheral_id, size_t peripheral_len, + simble_response *out) { + uint8_t token[32]; + if (read_token(token) != 0) + return SIMBLE_ERR_TRUNCATED; + uint8_t payload[256]; + // DISCONNECT is op 6. + int n = simble_encode_peripheral_command(6, token, sizeof(token), peripheral_id, peripheral_len, + payload, sizeof(payload)); + return do_request(payload, n, out); +} + +simble_status simble_client_read_characteristic(const uint8_t *peripheral_id, size_t peripheral_len, + const char *service, size_t service_len, + const char *characteristic, size_t char_len, + simble_response *out) { + uint8_t token[32]; + if (read_token(token) != 0) + return SIMBLE_ERR_TRUNCATED; + uint8_t payload[512]; + int n = simble_encode_read_characteristic(token, sizeof(token), peripheral_id, peripheral_len, + service, service_len, characteristic, char_len, payload, + sizeof(payload)); + return do_request(payload, n, out); +} + +simble_status simble_client_write_characteristic(const uint8_t *peripheral_id, + size_t peripheral_len, const char *service, + size_t service_len, const char *characteristic, + size_t char_len, const uint8_t *value, + size_t value_len, simble_write_type write_type, + simble_response *out) { + uint8_t token[32]; + if (read_token(token) != 0) + return SIMBLE_ERR_TRUNCATED; + uint8_t payload[2048]; + int n = simble_encode_write_characteristic(token, sizeof(token), peripheral_id, peripheral_len, + service, service_len, characteristic, char_len, value, + value_len, write_type, payload, sizeof(payload)); + return do_request(payload, n, out); +} + +simble_status simble_client_set_notify(const uint8_t *peripheral_id, size_t peripheral_len, + const char *service, size_t service_len, + const char *characteristic, size_t char_len, int enabled, + simble_response *out) { + uint8_t token[32]; + if (read_token(token) != 0) + return SIMBLE_ERR_TRUNCATED; + uint8_t payload[512]; + int n = simble_encode_set_notify(token, sizeof(token), peripheral_id, peripheral_len, service, + service_len, characteristic, char_len, enabled, payload, + sizeof(payload)); + return do_request(payload, n, out); +} + +simble_status simble_client_read_rssi(const uint8_t *peripheral_id, size_t peripheral_len, + simble_response *out) { + uint8_t token[32]; + if (read_token(token) != 0) + return SIMBLE_ERR_TRUNCATED; + uint8_t payload[256]; + // READ_RSSI is op 12. + int n = simble_encode_peripheral_command(12, token, sizeof(token), peripheral_id, peripheral_len, + payload, sizeof(payload)); + return do_request(payload, n, out); +} + +simble_status simble_client_open(simble_conn *conn) { + uint8_t token[32]; + if (read_token(token) != 0) + return SIMBLE_ERR_TRUNCATED; + int fd = connect_helper(); + if (fd < 0) + return SIMBLE_ERR_TRUNCATED; + conn->fd = fd; + return SIMBLE_OK; +} + +simble_status simble_client_read_event(simble_conn *conn, simble_event *out) { + if (!conn || conn->fd < 0) + return SIMBLE_ERR_TRUNCATED; + uint8_t frame[8192]; + size_t frame_len = 0; + simble_status st = read_frame(conn->fd, frame, sizeof(frame), &frame_len); + if (st != SIMBLE_OK) + return st; + return simble_decode_event(frame, frame_len, out); +} + +void simble_client_close(simble_conn *conn) { + if (conn && conn->fd >= 0) { + close(conn->fd); + conn->fd = -1; + } +} diff --git a/packages/interpose/src/transport/client.h b/packages/interpose/src/transport/client.h new file mode 100644 index 0000000..23909de --- /dev/null +++ b/packages/interpose/src/transport/client.h @@ -0,0 +1,223 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * SPDX-FileCopyrightText: 2026 Nirapod Labs + */ + +/** + * @file client.h + * @brief Loopback transport to the helper: one framed request per call, a decoded + * reply out. + * + * @details + * Connects to 127.0.0.1 at the port named by SIMBLE_PORT, sends one framed request + * carrying the capability token from SIMBLE_TOKEN in key 7, reads one 4-byte + * big-endian length-prefixed reply, and decodes it with the simble_protocol C codec. + * One connection per request. A reply is a response or an unsolicited event; the + * request functions return responses, and ::simble_client_read_event drains the + * stream of events a long-lived connection carries. + * + * Every function returns the codec's ::simble_status. A missing or malformed port + * or token, a failed connect, and a short read surface as ::SIMBLE_ERR_TRUNCATED; + * an oversized frame as ::SIMBLE_ERR_BUFFER; a bad length prefix as + * ::SIMBLE_ERR_MALFORMED. + * + * @author Nirapod Labs + * @date 2026 + */ + +#ifndef SIMBLE_CLIENT_H +#define SIMBLE_CLIENT_H + +#include "simble_protocol.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @defgroup simble_client Loopback client + * @brief One-shot request functions and an event-stream reader the hooks use. + * @{ + */ + +/** An open connection to the helper, for a path that reads events after a request. */ +typedef struct { + int fd; ///< The connected socket, or -1 when not open. +} simble_conn; + +/** + * @brief Run the HELLO version handshake, announcing the guest's identity for display. + * + * The identity is optional and best-effort: the bundle id and display name, each omitted when + * its pointer is NULL or length is 0. It names the connecting app so the helper can show it and + * gates nothing; the helper sanitizes the name before use. + * + * @param[in] version Protocol version this side speaks. + * @param[in] app_id Guest bundle id, UTF-8, or NULL. + * @param[in] app_id_len Length of @p app_id; 0 omits it. + * @param[in] display_name Guest display name, UTF-8, or NULL. + * @param[in] display_name_len Length of @p display_name; 0 omits it. + * @param[out] out The decoded response; ::SIMBLE_RESP_HELLO on success. + * @return ::SIMBLE_OK when a response decoded cleanly, a ::simble_status error otherwise. + */ +simble_status simble_client_hello(uint64_t version, const uint8_t *app_id, size_t app_id_len, + const uint8_t *display_name, size_t display_name_len, + simble_response *out); + +/** + * @brief Read the host central manager's CBManagerState. + * + * @param[out] out The decoded response; ::SIMBLE_RESP_CENTRAL_STATE on success. + * @return ::SIMBLE_OK when a response decoded cleanly, a ::simble_status error otherwise. + */ +simble_status simble_client_central_state(simble_response *out); + +/** + * @brief Begin scanning, optionally filtered to a set of service UUIDs. + * + * @param[in] uuids Array of UTF-8 service UUID strings, or NULL for no filter. + * @param[in] uuid_lens Per-UUID lengths, parallel to @p uuids. + * @param[in] uuid_count Number of UUIDs; 0 omits the filter. + * @param[out] out The decoded response; ::SIMBLE_RESP_CONFIRMED on success. + * @return ::SIMBLE_OK when a response decoded cleanly, a ::simble_status error otherwise. + */ +simble_status simble_client_scan_start(const char *const *uuids, const size_t *uuid_lens, + size_t uuid_count, simble_response *out); + +/** + * @brief End scanning. + * + * @param[out] out The decoded response; ::SIMBLE_RESP_CONFIRMED on success. + * @return ::SIMBLE_OK when a response decoded cleanly, a ::simble_status error otherwise. + */ +simble_status simble_client_scan_stop(simble_response *out); + +/** + * @brief Connect to a peripheral named by its host identifier. + * + * @param[in] peripheral_id Host CBPeripheral.identifier bytes. + * @param[in] peripheral_len Length of @p peripheral_id. + * @param[out] out The decoded response; ::SIMBLE_RESP_PERIPHERAL on success. + * @return ::SIMBLE_OK when a response decoded cleanly, a ::simble_status error otherwise. + */ +simble_status simble_client_connect(const uint8_t *peripheral_id, size_t peripheral_len, + simble_response *out); + +/** + * @brief Disconnect a peripheral named by its host identifier. + * + * @param[in] peripheral_id Host peripheral identifier bytes. + * @param[in] peripheral_len Length of @p peripheral_id. + * @param[out] out The decoded response; ::SIMBLE_RESP_PERIPHERAL on success. + * @return ::SIMBLE_OK when a response decoded cleanly, a ::simble_status error otherwise. + */ +simble_status simble_client_disconnect(const uint8_t *peripheral_id, size_t peripheral_len, + simble_response *out); + +/** + * @brief Read a characteristic value from a connected peripheral. + * + * @param[in] peripheral_id Host peripheral identifier bytes. + * @param[in] peripheral_len Length of @p peripheral_id. + * @param[in] service Service UUID, UTF-8. + * @param[in] service_len Length of @p service. + * @param[in] characteristic Characteristic UUID, UTF-8. + * @param[in] char_len Length of @p characteristic. + * @param[out] out The decoded response; ::SIMBLE_RESP_CHAR_VALUE on success. + * @return ::SIMBLE_OK when a response decoded cleanly, a ::simble_status error otherwise. + */ +simble_status simble_client_read_characteristic(const uint8_t *peripheral_id, size_t peripheral_len, + const char *service, size_t service_len, + const char *characteristic, size_t char_len, + simble_response *out); + +/** + * @brief Write a characteristic value to a connected peripheral, with or without a response. + * + * @param[in] peripheral_id Host peripheral identifier bytes. + * @param[in] peripheral_len Length of @p peripheral_id. + * @param[in] service Service UUID, UTF-8. + * @param[in] service_len Length of @p service. + * @param[in] characteristic Characteristic UUID, UTF-8. + * @param[in] char_len Length of @p characteristic. + * @param[in] value Value bytes to write. + * @param[in] value_len Length of @p value. + * @param[in] write_type A ::simble_write_type. + * @param[out] out The decoded response; ::SIMBLE_RESP_CONFIRMED on success. + * @return ::SIMBLE_OK when a response decoded cleanly, a ::simble_status error otherwise. + */ +simble_status simble_client_write_characteristic(const uint8_t *peripheral_id, + size_t peripheral_len, const char *service, + size_t service_len, const char *characteristic, + size_t char_len, const uint8_t *value, + size_t value_len, simble_write_type write_type, + simble_response *out); + +/** + * @brief Enable or disable notifications on a characteristic. + * + * @param[in] peripheral_id Host peripheral identifier bytes. + * @param[in] peripheral_len Length of @p peripheral_id. + * @param[in] service Service UUID, UTF-8. + * @param[in] service_len Length of @p service. + * @param[in] characteristic Characteristic UUID, UTF-8. + * @param[in] char_len Length of @p characteristic. + * @param[in] enabled Non-zero enables notifications. + * @param[out] out The decoded response; ::SIMBLE_RESP_NOTIFY_STATE on success. + * @return ::SIMBLE_OK when a response decoded cleanly, a ::simble_status error otherwise. + */ +simble_status simble_client_set_notify(const uint8_t *peripheral_id, size_t peripheral_len, + const char *service, size_t service_len, + const char *characteristic, size_t char_len, int enabled, + simble_response *out); + +/** + * @brief Read a connected peripheral's signal strength. + * + * @param[in] peripheral_id Host peripheral identifier bytes. + * @param[in] peripheral_len Length of @p peripheral_id. + * @param[out] out The decoded response; ::SIMBLE_RESP_RSSI on success. + * @return ::SIMBLE_OK when a response decoded cleanly, a ::simble_status error otherwise. + */ +simble_status simble_client_read_rssi(const uint8_t *peripheral_id, size_t peripheral_len, + simble_response *out); + +/** + * @brief Open a connection to the helper, for a path that reads events after sending a request. + * + * The hooks use the one-shot request functions for directed operations; the event stream the + * helper raises unsolicited (a scan result, a notification, a disconnect) is read from a + * connection opened here and drained with ::simble_client_read_event. + * + * @param[out] conn Set to the open connection on success. + * @return ::SIMBLE_OK on a connect, ::SIMBLE_ERR_TRUNCATED when the port or token is absent or the + * connect failed. + */ +simble_status simble_client_open(simble_conn *conn); + +/** + * @brief Read and decode one event frame from an open connection. + * + * Blocks until a frame arrives or the connection closes. The reply is decoded as an event; a frame + * that is a response rather than an event is reported as ::SIMBLE_ERR_OPCODE. + * + * @param[in] conn The connection opened with ::simble_client_open. + * @param[out] out The decoded event. + * @return ::SIMBLE_OK on a clean event, a ::simble_status error otherwise. + */ +simble_status simble_client_read_event(simble_conn *conn, simble_event *out); + +/** + * @brief Close an open connection. + * + * @param[in,out] conn The connection to close; its fd is reset to -1. + */ +void simble_client_close(simble_conn *conn); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/packages/interpose/tests/client_roundtrip.c b/packages/interpose/tests/client_roundtrip.c new file mode 100644 index 0000000..57835e9 --- /dev/null +++ b/packages/interpose/tests/client_roundtrip.c @@ -0,0 +1,368 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * SPDX-FileCopyrightText: 2026 Nirapod Labs + */ + +/** + * @file client_roundtrip.c + * @brief Transport round-trip: the client frames a request and decodes a response and an event. + * + * @details + * An in-test loopback echo, bound to an ephemeral port on 127.0.0.1, plays the helper: it reads + * each request frame, confirms the capability token rides in it, and replies with a canonical + * response built here. One connection also receives an event frame, so the client's event reader + * is exercised. The transport reuses the simble_protocol C codec for the request encoding and the + * response and event decoding; the canned replies are hand-built canonical CBOR, so the codec is + * the thing under test, with no radio and no helper. + * + * @author Nirapod Labs + * @date 2026 + */ + +#include "client.h" +#include "simble_protocol.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +static int fails = 0; +#define CHECK(cond, msg) \ + do { \ + if (!(cond)) { \ + printf("FAIL: %s\n", msg); \ + fails++; \ + } \ + } while (0) + +// A canonical CBOR head: the major type in the top three bits, then the shortest-form argument. +static size_t put_head(uint8_t *out, uint8_t major, uint64_t value) { + uint8_t tag = (uint8_t)(major << 5); + if (value < 24) { + out[0] = (uint8_t)(tag | value); + return 1; + } + if (value < 0x100) { + out[0] = (uint8_t)(tag | 24); + out[1] = (uint8_t)value; + return 2; + } + out[0] = (uint8_t)(tag | 25); + out[1] = (uint8_t)(value >> 8); + out[2] = (uint8_t)value; + return 3; +} + +static size_t put_kv_uint(uint8_t *out, uint64_t key, uint64_t value) { + size_t n = put_head(out, 0, key); + return n + put_head(out + n, 0, value); +} + +static size_t put_kv_text(uint8_t *out, uint64_t key, const char *text) { + size_t n = put_head(out, 0, key); + size_t len = strlen(text); + n += put_head(out + n, 3, len); + memcpy(out + n, text, len); + return n + len; +} + +static size_t put_kv_bytes(uint8_t *out, uint64_t key, const uint8_t *bytes, size_t len) { + size_t n = put_head(out, 0, key); + n += put_head(out + n, 2, len); + memcpy(out + n, bytes, len); + return n + len; +} + +static int write_all(int fd, const uint8_t *buf, size_t len) { + size_t written = 0; + while (written < len) { + ssize_t n = send(fd, buf + written, len - written, 0); + if (n <= 0) + return -1; + written += (size_t)n; + } + return 0; +} + +static int read_all(int fd, uint8_t *buf, size_t len) { + size_t got = 0; + while (got < len) { + ssize_t n = recv(fd, buf + got, len - got, 0); + if (n <= 0) + return -1; + got += (size_t)n; + } + return 0; +} + +// Send one payload as a 4-byte big-endian length-prefixed frame. +static int send_frame(int fd, const uint8_t *payload, size_t len) { + uint8_t prefix[4] = {(uint8_t)(len >> 24), (uint8_t)(len >> 16), (uint8_t)(len >> 8), + (uint8_t)len}; + if (write_all(fd, prefix, 4) != 0) + return -1; + return write_all(fd, payload, len); +} + +// Read one request frame; confirm it carries the 32-byte token (key 7) and return its op (key 0). +static int read_request(int fd, uint64_t *op_out) { + uint8_t prefix[4]; + if (read_all(fd, prefix, 4) != 0) + return -1; + uint32_t len = ((uint32_t)prefix[0] << 24) | ((uint32_t)prefix[1] << 16) | + ((uint32_t)prefix[2] << 8) | (uint32_t)prefix[3]; + if (len == 0 || len > 8192) + return -1; + uint8_t *buf = malloc(len); + if (!buf) + return -1; + if (read_all(fd, buf, len) != 0) { + free(buf); + return -1; + } + // The first map entry is op: key 0 (byte 0xa? then 0x00) then the op uint. Read it directly. + // buf[0] is the map head; buf[1] is key 0; buf[2..] is the op value head. + uint64_t op = 0; + if (len >= 3 && (buf[1] == 0x00)) { + uint8_t info = buf[2] & 0x1F; + if (info < 24) { + op = info; + } else if (info == 24 && len >= 4) { + op = buf[3]; + } + } + // Confirm a 32-byte byte string for key 7 appears somewhere in the payload: 0x07 0x58 0x20. + int token_ok = 0; + for (uint32_t i = 0; i + 2 < len; i++) { + if (buf[i] == 0x07 && buf[i + 1] == 0x58 && buf[i + 2] == 0x20) { + token_ok = 1; + break; + } + } + free(buf); + if (!token_ok) + return -1; + *op_out = op; + return 0; +} + +static const uint8_t kPeripheralId[] = {0x11, 0x22, 0x33, 0x44}; + +// Build the canonical response the helper would send for an op, returning its length. +static size_t build_response(uint64_t op, uint8_t *out) { + size_t n = 0; + switch (op) { + case 1: // HELLO: { 0:1, 1:0, 8:1 } + n += put_head(out + n, 5, 3); + n += put_kv_uint(out + n, 0, 1); + n += put_kv_uint(out + n, 1, 0); + n += put_kv_uint(out + n, 8, 1); + return n; + case 2: // CENTRAL_STATE: { 0:2, 1:0, 41:5 } (5 == poweredOn) + n += put_head(out + n, 5, 3); + n += put_kv_uint(out + n, 0, 2); + n += put_kv_uint(out + n, 1, 0); + n += put_kv_uint(out + n, 41, 5); + return n; + case 3: // SCAN_START: { 0:3, 1:0 } + case 4: // SCAN_STOP: { 0:4, 1:0 } + n += put_head(out + n, 5, 2); + n += put_kv_uint(out + n, 0, op); + n += put_kv_uint(out + n, 1, 0); + return n; + case 5: // CONNECT: { 0:5, 1:0, 30: } + case 6: // DISCONNECT: { 0:6, 1:0, 30: } + n += put_head(out + n, 5, 3); + n += put_kv_uint(out + n, 0, op); + n += put_kv_uint(out + n, 1, 0); + n += put_kv_bytes(out + n, 30, kPeripheralId, sizeof(kPeripheralId)); + return n; + case 9: // READ_CHARACTERISTIC: { 0:9, 1:0, 30:, 31:svc, 32:chr, 33:val } + n += put_head(out + n, 5, 6); + n += put_kv_uint(out + n, 0, 9); + n += put_kv_uint(out + n, 1, 0); + n += put_kv_bytes(out + n, 30, kPeripheralId, sizeof(kPeripheralId)); + n += put_kv_text(out + n, 31, "180D"); + n += put_kv_text(out + n, 32, "2A37"); + { + const uint8_t val[] = {0x48, 0x69}; + n += put_kv_bytes(out + n, 33, val, sizeof(val)); + } + return n; + case 10: // WRITE_CHARACTERISTIC: { 0:10, 1:0 } + n += put_head(out + n, 5, 2); + n += put_kv_uint(out + n, 0, 10); + n += put_kv_uint(out + n, 1, 0); + return n; + case 11: // SET_NOTIFY: { 0:11, 1:0, 30:, 31:svc, 32:chr, 40:1 } + n += put_head(out + n, 5, 6); + n += put_kv_uint(out + n, 0, 11); + n += put_kv_uint(out + n, 1, 0); + n += put_kv_bytes(out + n, 30, kPeripheralId, sizeof(kPeripheralId)); + n += put_kv_text(out + n, 31, "180D"); + n += put_kv_text(out + n, 32, "2A37"); + n += put_kv_uint(out + n, 40, 1); + return n; + case 12: // READ_RSSI: { 0:12, 1:0, 30:, 34:-50 } + n += put_head(out + n, 5, 4); + n += put_kv_uint(out + n, 0, 12); + n += put_kv_uint(out + n, 1, 0); + n += put_kv_bytes(out + n, 30, kPeripheralId, sizeof(kPeripheralId)); + n += put_head(out + n, 0, 34); + n += put_head(out + n, 1, 49); // CBOR negint 49 encodes -50 + return n; + default: + return 0; + } +} + +// A DISCOVERED event: { 0:128, 30:, 34:-40 }. +static size_t build_discovered_event(uint8_t *out) { + size_t n = 0; + n += put_head(out + n, 5, 3); + n += put_kv_uint(out + n, 0, 128); + n += put_kv_bytes(out + n, 30, kPeripheralId, sizeof(kPeripheralId)); + n += put_head(out + n, 0, 34); + n += put_head(out + n, 1, 39); // CBOR negint 39 encodes -40 + return n; +} + +typedef struct { + uint16_t port; + int ready; + pthread_mutex_t lock; + pthread_cond_t cond; +} server_ctx; + +// The echo server: accept connections, answer each request, and on the last connection send an +// event frame before closing. +static void *server_thread(void *arg) { + server_ctx *ctx = arg; + int listen_fd = socket(AF_INET, SOCK_STREAM, 0); + int reuse = 1; + setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = 0; + addr.sin_addr.s_addr = inet_addr("127.0.0.1"); + bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)); + listen(listen_fd, 16); + socklen_t alen = sizeof(addr); + getsockname(listen_fd, (struct sockaddr *)&addr, &alen); + + pthread_mutex_lock(&ctx->lock); + ctx->port = ntohs(addr.sin_port); + ctx->ready = 1; + pthread_cond_signal(&ctx->cond); + pthread_mutex_unlock(&ctx->lock); + + // Serve one connection per directed request, then one connection that carries an event. + for (int i = 0; i < 10; i++) { + int fd = accept(listen_fd, NULL, NULL); + if (fd < 0) + break; + uint64_t op = 0; + if (read_request(fd, &op) == 0) { + uint8_t resp[256]; + size_t len = build_response(op, resp); + if (len) + send_frame(fd, resp, len); + } + close(fd); + } + // The event connection: the client opens it, the server pushes one event frame. + int evt_fd = accept(listen_fd, NULL, NULL); + if (evt_fd >= 0) { + uint8_t evt[64]; + size_t len = build_discovered_event(evt); + send_frame(evt_fd, evt, len); + close(evt_fd); + } + close(listen_fd); + return NULL; +} + +int main(void) { + server_ctx ctx = {0, 0, PTHREAD_MUTEX_INITIALIZER, PTHREAD_COND_INITIALIZER}; + pthread_t thread; + pthread_create(&thread, NULL, server_thread, &ctx); + + pthread_mutex_lock(&ctx.lock); + while (!ctx.ready) + pthread_cond_wait(&ctx.cond, &ctx.lock); + uint16_t port = ctx.port; + pthread_mutex_unlock(&ctx.lock); + + char port_str[16]; + snprintf(port_str, sizeof(port_str), "%u", port); + setenv("SIMBLE_PORT", port_str, 1); + setenv("SIMBLE_TOKEN", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", 1); + + simble_response resp; + CHECK(simble_client_hello(SIMBLE_PROTOCOL_VERSION, NULL, 0, NULL, 0, &resp) == SIMBLE_OK && + resp.kind == SIMBLE_RESP_HELLO && resp.version == 1, + "hello negotiates v1"); + + CHECK(simble_client_central_state(&resp) == SIMBLE_OK && resp.kind == SIMBLE_RESP_CENTRAL_STATE && + resp.manager_state == 5, + "central state is poweredOn"); + + const char *uuid = "180D"; + const size_t uuid_len = 4; + CHECK(simble_client_scan_start(&uuid, &uuid_len, 1, &resp) == SIMBLE_OK && + resp.kind == SIMBLE_RESP_CONFIRMED, + "scan start confirmed"); + CHECK(simble_client_scan_stop(&resp) == SIMBLE_OK && resp.kind == SIMBLE_RESP_CONFIRMED, + "scan stop confirmed"); + + CHECK(simble_client_connect(kPeripheralId, sizeof(kPeripheralId), &resp) == SIMBLE_OK && + resp.kind == SIMBLE_RESP_PERIPHERAL && resp.peripheral_len == sizeof(kPeripheralId) && + memcmp(resp.peripheral, kPeripheralId, sizeof(kPeripheralId)) == 0, + "connect echoes the peripheral id"); + + CHECK(simble_client_read_characteristic(kPeripheralId, sizeof(kPeripheralId), "180D", 4, "2A37", + 4, &resp) == SIMBLE_OK && + resp.kind == SIMBLE_RESP_CHAR_VALUE && resp.value_len == 2 && resp.value[0] == 0x48 && + resp.value[1] == 0x69, + "read characteristic returns the value"); + + const uint8_t wv[] = {0x01}; + CHECK(simble_client_write_characteristic(kPeripheralId, sizeof(kPeripheralId), "180D", 4, "2A37", + 4, wv, sizeof(wv), SIMBLE_WRITE_WITH_RESPONSE, + &resp) == SIMBLE_OK && + resp.kind == SIMBLE_RESP_CONFIRMED, + "write characteristic confirmed"); + + CHECK(simble_client_set_notify(kPeripheralId, sizeof(kPeripheralId), "180D", 4, "2A37", 4, 1, + &resp) == SIMBLE_OK && + resp.kind == SIMBLE_RESP_NOTIFY_STATE && resp.notify == 1, + "set notify enabled"); + + CHECK(simble_client_read_rssi(kPeripheralId, sizeof(kPeripheralId), &resp) == SIMBLE_OK && + resp.kind == SIMBLE_RESP_RSSI && resp.rssi == -50, + "read rssi returns the signal strength"); + + CHECK(simble_client_disconnect(kPeripheralId, sizeof(kPeripheralId), &resp) == SIMBLE_OK && + resp.kind == SIMBLE_RESP_PERIPHERAL, + "disconnect echoes the peripheral id"); + + // The event stream: open a connection and read one DISCOVERED event. + simble_conn conn; + CHECK(simble_client_open(&conn) == SIMBLE_OK, "open the event connection"); + simble_event event; + CHECK(simble_client_read_event(&conn, &event) == SIMBLE_OK && + event.kind == SIMBLE_EVT_DISCOVERED && event.rssi == -40 && + event.peripheral_len == sizeof(kPeripheralId), + "read a discovered event"); + simble_client_close(&conn); + + pthread_join(thread, NULL); + printf(fails ? "CLIENT ROUNDTRIP: %d failure(s)\n" : "CLIENT ROUNDTRIP: ok\n", fails); + return fails ? 1 : 0; +} diff --git a/packages/interpose/tests/hook_smoke.c b/packages/interpose/tests/hook_smoke.c index 9d5dbe2..1076f0e 100644 --- a/packages/interpose/tests/hook_smoke.c +++ b/packages/interpose/tests/hook_smoke.c @@ -3,11 +3,45 @@ * SPDX-FileCopyrightText: 2026 Nirapod Labs */ +/** + * @file hook_smoke.c + * @brief The swizzles install and uninstall cleanly on the host. + * + * @details + * The host has the CoreBluetooth classes the swizzles target, so a plain ctest can install + * every swizzle, confirm none failed, and uninstall, with no radio and no helper. The + * version entry point is checked too. + * + * @author Nirapod Labs + * @date 2026 + */ + #include "simble_interpose.h" #include +#include int main(void) { assert(simble_interpose_version() == 1); + + int failures = simble_install_hooks(); + printf("install: %d swizzle(s) failed\n", failures); + if (failures != 0) + return 1; + + // Idempotent: a second install while installed is a no-op and reports no failures. + if (simble_install_hooks() != 0) + return 1; + + simble_uninstall_hooks(); + // Idempotent: an uninstall while nothing is installed is a no-op. + simble_uninstall_hooks(); + + // Reinstall after uninstall, to prove the exchange restored a swizzleable original. + if (simble_install_hooks() != 0) + return 1; + simble_uninstall_hooks(); + + printf("HOOK SMOKE: ok\n"); return 0; } diff --git a/packages/interpose/tests/passthrough_test.m b/packages/interpose/tests/passthrough_test.m new file mode 100644 index 0000000..7704c6f --- /dev/null +++ b/packages/interpose/tests/passthrough_test.m @@ -0,0 +1,68 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * SPDX-FileCopyrightText: 2026 Nirapod Labs + */ + +/** + * @file passthrough_test.m + * @brief The passthrough invariant as a first-class test. + * + * @details + * A CBCentralManager the registry did not mint is unaffected by the installed swizzle: the + * routed methods consult the registry, miss, and call the original implementation. The test + * proves the managed split by registry membership, then drives a non-managed manager's swizzled + * methods and confirms its state reads the same with the swizzle installed as without it, with + * no radio and no helper. + * + * @author Nirapod Labs + * @date 2026 + */ + +#import "../include/simble_interpose.h" +#import "shadow.h" + +#import +#import + +static int fails = 0; +#define CHECK(cond, msg) \ + do { \ + if (!(cond)) { \ + printf("FAIL: %s\n", msg); \ + fails++; \ + } \ + } while (0) + +int main(void) { + @autoreleasepool { + simble_shadow_reset(); + + // A manager the registry never registered, the non-managed receiver. Its state before any + // swizzle is the baseline. + CBCentralManager *stray = [CBCentralManager alloc]; + CBManagerState before = stray.state; + + int failures = simble_install_hooks(); + CHECK(failures == 0, "swizzles install"); + + // The routed methods gate on registry membership. A stray manager is not managed, so the + // swizzled state reads the original value, byte-identical to the pre-install read. + CHECK(!simble_shadow_is_managed_manager(stray), "a stray manager is not managed"); + CBManagerState after = stray.state; + printf("stray state: %ld == %ld\n", (long)before, (long)after); + CHECK(before == after, "a non-managed manager's state is unaffected by the swizzle"); + + // Non-managed scan/stop reach the original implementation; the routed counter must not move. + simble_hook_stats start = simble_get_hook_stats(); + [stray scanForPeripheralsWithServices:nil options:nil]; + [stray stopScan]; + simble_hook_stats end = simble_get_hook_stats(); + CHECK(start.scan_start == end.scan_start, "a non-managed scan does not count as routed"); + + simble_uninstall_hooks(); + simble_shadow_reset(); + } + + printf(fails ? "PASSTHROUGH: %d failure(s)\n" : "PASSTHROUGH: ok\n", fails); + return fails ? 1 : 0; +} diff --git a/packages/interpose/tests/run-mechanism-central.sh b/packages/interpose/tests/run-mechanism-central.sh new file mode 100755 index 0000000..ffe05c3 --- /dev/null +++ b/packages/interpose/tests/run-mechanism-central.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 Nirapod Labs +# +# The in-simulator central lane: the interposer slice, injected into a guest app booted in the +# iOS Simulator, routes the guest's CoreBluetooth central calls to the helper, which drives the +# Mac's real radio and streams events back as the guest's delegate callbacks. This needs a booted +# simulator, the running helper, and a real BLE peripheral in range, so it runs on a self-hosted +# machine with Bluetooth, not in CI. It is not a CI gate. +# +# How it runs, once the pieces exist: +# 1. Build the iOS interposer slice: make configure && cmake --build build-sim +# The slice is build-sim/bin/simble-interpose.so, named simble-interpose. +# 2. Build and start the helper, which prints {"ready":true,"port":} on stdout and exposes +# the per-session capability token in lowercase hex through its session transport. +# 3. Boot a simulator and install a guest app that uses CoreBluetooth as a central. +# 4. Launch the guest with the slice injected and the helper pointed at, through the debug +# scheme that sets, behind the fence (debug-only, allowlisted): the dyld insert list pointing +# at the slice, SIMBLE_PORT at the helper port, and SIMBLE_TOKEN at the session token hex. +# 5. Drive the guest to scan, connect, discover, read, write, and subscribe; assert it sees the +# real peripheral's advertisements and characteristic values through its own delegate. +# +# The radio-free half of this lane runs as host ctests (make test): the swizzle install and +# uninstall (hook_smoke), the shadow registry mint and fail-closed (shadow_registry), the +# passthrough invariant (passthrough), and the transport round-trip against an in-test loopback +# (client_roundtrip). +set -uo pipefail + +echo "run-mechanism-central is the self-hosted in-simulator lane; it is not wired as a CI gate." +echo "See the header of this script for how it runs against a booted simulator and the helper." +exit 0 diff --git a/packages/interpose/tests/shadow_registry_test.m b/packages/interpose/tests/shadow_registry_test.m new file mode 100644 index 0000000..6d89f62 --- /dev/null +++ b/packages/interpose/tests/shadow_registry_test.m @@ -0,0 +1,103 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * SPDX-FileCopyrightText: 2026 Nirapod Labs + */ + +/** + * @file shadow_registry_test.m + * @brief The shadow registry: mint, look up, and fail closed. + * + * @details + * Minting returns one stable stand-in per host identifier, lookups resolve a minted + * object back to its host identity, and an object the registry never minted resolves + * to nothing, which is the fail-closed property the routing path depends on. No radio + * and no helper. + * + * @author Nirapod Labs + * @date 2026 + */ + +#import "shadow.h" + +#import +#import + +static int fails = 0; +#define CHECK(cond, msg) \ + do { \ + if (!(cond)) { \ + printf("FAIL: %s\n", msg); \ + fails++; \ + } \ + } while (0) + +int main(void) { + @autoreleasepool { + simble_shadow_reset(); + + // A manager is managed once registered, and not before. + CBCentralManager *manager = [CBCentralManager alloc]; + CHECK(!simble_shadow_is_managed_manager(manager), "an unregistered manager is not managed"); + simble_shadow_register_manager(manager, nil, nil); + CHECK(simble_shadow_is_managed_manager(manager), "a registered manager is managed"); + + // One stand-in per host identifier: the same id mints the same peripheral. + const uint8_t idBytes[] = {0xde, 0xad, 0xbe, 0xef}; + CBPeripheral *p1 = simble_shadow_peripheral(manager, idBytes, sizeof(idBytes)); + CBPeripheral *p2 = simble_shadow_peripheral(manager, idBytes, sizeof(idBytes)); + CHECK(p1 != nil && p1 == p2, "one stand-in per identifier"); + CHECK(simble_shadow_is_managed_peripheral(p1), "a minted peripheral is managed"); + CHECK(simble_shadow_owner(p1) == manager, "the owner resolves to the minting manager"); + + // The peripheral id round-trips. + uint8_t out[64]; + size_t outLen = 0; + CHECK(simble_shadow_peripheral_id(p1, out, sizeof(out), &outLen) && outLen == sizeof(idBytes) && + memcmp(out, idBytes, outLen) == 0, + "the peripheral id round-trips"); + + // A different id mints a different stand-in. + const uint8_t otherBytes[] = {0x01, 0x02}; + CBPeripheral *other = simble_shadow_peripheral(manager, otherBytes, sizeof(otherBytes)); + CHECK(other != nil && other != p1, "a different identifier mints a different stand-in"); + + // Services and characteristics mint under a minted peripheral and resolve back. + CBUUID *serviceUUID = [CBUUID UUIDWithString:@"180D"]; + CBUUID *charUUID = [CBUUID UUIDWithString:@"2A37"]; + CBService *service = simble_shadow_service(p1, serviceUUID); + CHECK(service != nil && service == simble_shadow_service(p1, serviceUUID), + "one service stand-in per uuid"); + CBCharacteristic *characteristic = simble_shadow_characteristic(service, charUUID); + CHECK(characteristic != nil && + characteristic == simble_shadow_characteristic(service, charUUID), + "one characteristic stand-in per uuid"); + + uint8_t rid[64]; + size_t ridLen = 0; + CBUUID *resolvedService = nil; + CBUUID *resolvedChar = nil; + CHECK(simble_shadow_resolve_characteristic(characteristic, rid, sizeof(rid), &ridLen, + &resolvedService, &resolvedChar), + "a minted characteristic resolves"); + CHECK(ridLen == sizeof(idBytes) && memcmp(rid, idBytes, ridLen) == 0, + "the resolved peripheral id matches the peripheral"); + CHECK([resolvedService isEqual:serviceUUID] && [resolvedChar isEqual:charUUID], + "the resolved uuids match"); + + // Fail closed: an object the registry never minted is not managed and does not resolve. + CBCentralManager *strayManager = [CBCentralManager alloc]; + CHECK(!simble_shadow_is_managed_manager(strayManager), "a stray manager is not managed"); + CHECK(simble_shadow_peripheral(strayManager, idBytes, sizeof(idBytes)) == nil, + "minting on an unregistered manager fails closed"); + CHECK(!simble_shadow_is_managed_peripheral((CBPeripheral *)manager), + "a non-minted object is not a managed peripheral"); + CHECK(simble_shadow_service((CBPeripheral *)manager, serviceUUID) == nil, + "a service on a non-minted peripheral fails closed"); + + simble_shadow_reset(); + CHECK(!simble_shadow_is_managed_manager(manager), "reset drops every registration"); + } + + printf(fails ? "SHADOW REGISTRY: %d failure(s)\n" : "SHADOW REGISTRY: ok\n", fails); + return fails ? 1 : 0; +}