From 2f2e113ce66e87872af643eeb5fa52ebc2fccefc Mon Sep 17 00:00:00 2001 From: athexweb3 Date: Mon, 29 Jun 2026 20:00:33 +0600 Subject: [PATCH] fix(helper): coexist on dyld inserts, re-arm sims A single launchctl setenv overwrote any DYLD_INSERT_LIBRARIES a peer tool (e.g. SimBLE) had set, and arming ran only at helper start. Compose append-if-absent, remove only our own entry on teardown, and re-arm booted sims on a short cadence, so peers coexist and a boot, reboot, or clobber recovers without a manual toggle. The interposer now installs only when both port and token are set, and those are set before the insert var. --- .../SimEnclaveHelperKit/InjectionEnv.swift | 25 +++++++ .../simenclave-menubar/HelperModel.swift | 71 ++++++++++++++----- .../InjectionEnvTests.swift | 44 ++++++++++++ packages/interpose/src/entry.c | 9 +-- scripts/fence-check.sh | 4 ++ 5 files changed, 133 insertions(+), 20 deletions(-) create mode 100644 apps/helper/Sources/SimEnclaveHelperKit/InjectionEnv.swift create mode 100644 apps/helper/Tests/SimEnclaveHelperKitTests/InjectionEnvTests.swift diff --git a/apps/helper/Sources/SimEnclaveHelperKit/InjectionEnv.swift b/apps/helper/Sources/SimEnclaveHelperKit/InjectionEnv.swift new file mode 100644 index 0000000..a7a74f8 --- /dev/null +++ b/apps/helper/Sources/SimEnclaveHelperKit/InjectionEnv.swift @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +/// Composes the shared `DYLD_INSERT_LIBRARIES` simulator variable. Independent injection +/// tools share it instead of overwriting: each adds and removes only its own slice via +/// `composed`/`removed`. Tool-specific port and token vars stay namespaced (`SIMENCLAVE_*`, +/// `SIMBLE_*`) and are set and cleared directly. +public enum InjectionEnv { + /// Add `dylib` to a `DYLD_INSERT_LIBRARIES` value exactly once, preserving every other entry. + public static func composed(current: String?, adding dylib: String) -> String { + var entries = split(current) + entries.removeAll { $0 == dylib } + entries.append(dylib) + return entries.joined(separator: ":") + } + + /// Remove `dylib` from a `DYLD_INSERT_LIBRARIES` value, leaving every other tool's entry. + public static func removed(current: String?, removing dylib: String) -> String { + split(current).filter { $0 != dylib }.joined(separator: ":") + } + + private static func split(_ value: String?) -> [String] { + (value ?? "").split(separator: ":").map(String.init).filter { !$0.isEmpty } + } +} diff --git a/apps/helper/Sources/simenclave-menubar/HelperModel.swift b/apps/helper/Sources/simenclave-menubar/HelperModel.swift index 1a745e1..0e05dc8 100644 --- a/apps/helper/Sources/simenclave-menubar/HelperModel.swift +++ b/apps/helper/Sources/simenclave-menubar/HelperModel.swift @@ -87,6 +87,7 @@ final class HelperModel { private var listener: LoopbackListener? private var directory: String? private var observer: Observer? + private var rearmTimer: Timer? private static let fixedPortKey = "fixedPort" private static let lastPortKey = "lastPort" @@ -180,9 +181,12 @@ final class HelperModel { UserDefaults.standard.set(Int(port), forKey: Self.lastPortKey) TokenFile.writePort(port, toDirectory: dir) applyInjection() + startRearmLoop() } func stop() { + rearmTimer?.invalidate() + rearmTimer = nil clearInjection() listener?.stop() listener = nil @@ -205,30 +209,65 @@ final class HelperModel { guard let directory, let token = try? TokenFile.read(fromDirectory: directory).hex else { return } let port = self.port - Task.detached { - for sim in Self.bootedSimulators() { - // Skip a platform with no built slice rather than arm it with one its dyld rejects. - guard let dylib = Self.interposerDylib(for: sim.platform) else { continue } - let env = [ - ("DYLD_INSERT_LIBRARIES", dylib), - ("SIMENCLAVE_PORT", String(port)), - ("SIMENCLAVE_TOKEN", token), - ] - for (key, value) in env { - Self.runSimctl(["spawn", sim.udid, "launchctl", "setenv", key, value]) - } + Task.detached { Self.armBootedSimulators(port: port, token: token) } + } + + /// Re-arm booted simulators every 2s so a sim booted, rebooted, or clobbered after start + /// recovers without a manual toggle. Idempotent: each pass writes only a drifted variable. + private func startRearmLoop() { + rearmTimer?.invalidate() + rearmTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.applyInjection() } + } + } + + /// Arm every booted simulator on a platform we have a slice for. Idempotent. + private nonisolated static func armBootedSimulators(port: UInt16, token: String) { + for sim in bootedSimulators() { + guard let dylib = interposerDylib(for: sim.platform) else { continue } + // Set port and token before the DYLD entry: the interposer is inert without both, so a + // half-set env injects nothing rather than an app that cannot reach the helper. + if simulatorEnv(sim.udid, "SIMENCLAVE_PORT") != String(port) { + runSimctl(["spawn", sim.udid, "launchctl", "setenv", "SIMENCLAVE_PORT", String(port)]) + } + if simulatorEnv(sim.udid, "SIMENCLAVE_TOKEN") != token { + runSimctl(["spawn", sim.udid, "launchctl", "setenv", "SIMENCLAVE_TOKEN", token]) + } + // DYLD_INSERT_LIBRARIES is shared: add our slice via composed(), keeping every other + // tool's entry so SimEnclave and a peer (SimBLE) coexist. + let currentDyld = simulatorEnv(sim.udid, "DYLD_INSERT_LIBRARIES") + let composed = InjectionEnv.composed(current: currentDyld, adding: dylib) + if composed != (currentDyld ?? "") { + runSimctl(["spawn", sim.udid, "launchctl", "setenv", "DYLD_INSERT_LIBRARIES", composed]) } } } + /// Read one variable from a booted simulator's launchd environment, nil when unset. + private nonisolated static func simulatorEnv(_ udid: String, _ key: String) -> String? { + guard let output = runSimctlOutput(["spawn", udid, "launchctl", "getenv", key]) else { return nil } + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + /// Clear the simulator injection env. Synchronous, so a Quit or toggle-off finishes the /// cleanup before the helper goes away and a stale port/token cannot mislead a later app. func clearInjection() { - let keys = ["DYLD_INSERT_LIBRARIES", "SIMENCLAVE_PORT", "SIMENCLAVE_TOKEN"] - // Clear every booted sim regardless of platform: unsetting a variable that was never set - // is harmless, and an armed watch sim needs the same teardown as an ios one. for sim in Self.bootedSimulators() { - for key in keys { Self.runSimctl(["spawn", sim.udid, "launchctl", "unsetenv", key]) } + // Remove only our slice from the shared DYLD list; never blanket-unset it, which would + // also drop a peer tool's interposer. Unset the variable only when nothing else is left. + if let dylib = Self.interposerDylib(for: sim.platform) { + let current = Self.simulatorEnv(sim.udid, "DYLD_INSERT_LIBRARIES") + let remaining = InjectionEnv.removed(current: current, removing: dylib) + if remaining.isEmpty { + Self.runSimctl(["spawn", sim.udid, "launchctl", "unsetenv", "DYLD_INSERT_LIBRARIES"]) + } else if remaining != (current ?? "") { + Self.runSimctl(["spawn", sim.udid, "launchctl", "setenv", "DYLD_INSERT_LIBRARIES", remaining]) + } + } + // Our port and token are ours alone to clear. + Self.runSimctl(["spawn", sim.udid, "launchctl", "unsetenv", "SIMENCLAVE_PORT"]) + Self.runSimctl(["spawn", sim.udid, "launchctl", "unsetenv", "SIMENCLAVE_TOKEN"]) } } diff --git a/apps/helper/Tests/SimEnclaveHelperKitTests/InjectionEnvTests.swift b/apps/helper/Tests/SimEnclaveHelperKitTests/InjectionEnvTests.swift new file mode 100644 index 0000000..083b5c1 --- /dev/null +++ b/apps/helper/Tests/SimEnclaveHelperKitTests/InjectionEnvTests.swift @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Nirapod Labs + +import XCTest +@testable import SimEnclaveHelperKit + +final class InjectionEnvTests: XCTestCase { + let mine = "/tools/simenclave-interpose.dylib" + let other = "/tools/simble-interpose.dylib" + + func testComposedAddsToEmpty() { + XCTAssertEqual(InjectionEnv.composed(current: nil, adding: mine), mine) + XCTAssertEqual(InjectionEnv.composed(current: "", adding: mine), mine) + } + + func testComposedKeepsAnotherToolsEntry() { + // The coexistence case: a peer (SimBLE) armed first; our arm must not drop it. + XCTAssertEqual(InjectionEnv.composed(current: other, adding: mine), "\(other):\(mine)") + } + + func testComposedIsIdempotent() { + let once = InjectionEnv.composed(current: other, adding: mine) + XCTAssertEqual(InjectionEnv.composed(current: once, adding: mine), once) + } + + func testComposedDeduplicates() { + XCTAssertEqual( + InjectionEnv.composed(current: "\(mine):\(other):\(mine)", adding: mine), + "\(other):\(mine)") + } + + func testRemovedDropsOnlyOwnEntry() { + XCTAssertEqual(InjectionEnv.removed(current: "\(other):\(mine)", removing: mine), other) + } + + func testRemovedLeavesEmptyWhenSole() { + XCTAssertEqual(InjectionEnv.removed(current: mine, removing: mine), "") + } + + func testRemovedToleratesMissing() { + XCTAssertEqual(InjectionEnv.removed(current: other, removing: mine), other) + XCTAssertEqual(InjectionEnv.removed(current: nil, removing: mine), "") + } +} diff --git a/packages/interpose/src/entry.c b/packages/interpose/src/entry.c index d2a24c2..5dbef32 100644 --- a/packages/interpose/src/entry.c +++ b/packages/interpose/src/entry.c @@ -21,10 +21,11 @@ #include __attribute__((constructor)) static void simenclave_load(void) { - // Inert without configuration: a stray DYLD_INSERT_LIBRARIES outside a wired dev - // scheme installs nothing, which shrinks the blast radius of an accidental - // injection. This is not the fence; a release build bundles no dylib at all. - if (!getenv("SIMENCLAVE_PORT") && !getenv("SIMENCLAVE_TOKEN")) return; + // Inert unless fully configured: install hooks only when both port and token are set. + // A partially-set env (mid-arm, or a stray DYLD_INSERT_LIBRARIES) installs nothing. This + // shrinks the blast radius of an accidental injection; it is not the fence, which is that + // a release build bundles no dylib at all. + if (!getenv("SIMENCLAVE_PORT") || !getenv("SIMENCLAVE_TOKEN")) return; int failures = simenclave_install_hooks(); if (failures != 0) { fprintf(stderr, "[simenclave] %d hook(s) failed to install\n", failures); diff --git a/scripts/fence-check.sh b/scripts/fence-check.sh index 9fb472c..f053265 100755 --- a/scripts/fence-check.sh +++ b/scripts/fence-check.sh @@ -136,6 +136,9 @@ HITS="$(git ls-files 'project.yml' '*/project.yml' '*.pbxproj' '*.xcconfig' '*In # launchd environment so a launched app inherits the interposer. Dev-tool only, # never a shipped app; the same role as set-scheme-env.sh. Rule 2 still forbids # bundling the dylib, so this does not weaken the fence. +# apps/helper/Sources/SimEnclaveHelperKit/InjectionEnv.swift the append-if-absent +# composition the menubar arms with, so SimEnclave and a peer tool share the +# variable instead of overwriting it. Pure string logic; rule 2 still applies. # .github/workflows/ the fence and CI definitions # *.md and docs/ prose # *.xcscheme and *.xcconfig governed by rule 1 (debug-only), not this list @@ -148,6 +151,7 @@ allowed() { packages/interpose/tests/*) return 0 ;; packages/interpose/src/entry.c) return 0 ;; apps/helper/Sources/simenclave-menubar/HelperModel.swift) return 0 ;; + apps/helper/Sources/SimEnclaveHelperKit/InjectionEnv.swift) return 0 ;; .github/workflows/*) return 0 ;; *.md | docs/*) return 0 ;; *.xcscheme | *.xcconfig) return 0 ;;