Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/helper/Sources/SimEnclaveHelperKit/InjectionEnv.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
71 changes: 55 additions & 16 deletions apps/helper/Sources/simenclave-menubar/HelperModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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"])
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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), "")
}
}
9 changes: 5 additions & 4 deletions packages/interpose/src/entry.c
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@
#include <stdlib.h>

__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);
Expand Down
4 changes: 4 additions & 0 deletions scripts/fence-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ;;
Expand Down
Loading