mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
05f53502ab
> Recreates #543 — GitHub auto-closed it when the base branch of the stacked PR (#542) was deleted on merge, and a closed PR cannot be reopened after its head is force-pushed. Same commit, rebased onto main. ## Summary **Was stacked on #542** (now merged). Consumes the `ios.additionalDeviceSets` configuration across the iOS tool surface, so simulators living in extra CoreSimulator device sets — e.g. **Radon IDE's** `~/Library/Caches/com.swmansion.radon-ide/Devices/iOS` — are discovered by `list-devices` and drivable by every tool, by UDID, exactly like default-set devices. ### The core piece: a UDID → device-set map (`utils/ios-device-sets.ts`) simctl scopes every operation to one device set, so each per-device call must know the target's owning set. The new module owns that mapping and the single argv choke point: - **Learned from discovery**: `list-devices` and the simulator watcher enumerate the default set + every configured set and `rememberDeviceSet` each sighting — any tool used after a list hits a warm cache. - **Lazy fallback**: an unknown UDID triggers one `simctl list` probe per configured set, cached on hit. An unknown-everywhere UDID resolves to the default set **without** caching (a device that appears later is found on the next call) — preserving pre-feature behavior and error messages byte-for-byte. - **`simctlArgsForUdid(udid, args)`** builds the full argv (`--set` injected only for additional-set devices); `simctlPrefix` covers multi-call sites that resolve once; sync variants (`simctlArgsForUdidSync`, `cachedDeviceSetForUdid`) cover the `ChildProcess`-returning and `execFileSync` call sites, each of which runs after an async step that warmed the cache. - Config is **read off disk per call** (the #534 design): editing `ios.additionalDeviceSets` applies without a tool-server restart. - **Guard**: a configured set whose directory doesn't exist is never queried — simctl would materialize the dir, turning a config typo into a stray directory. ### Wired consumers - **`list-devices`** — enumerates every set, tags additional-set devices with `deviceSet`, documents the field. - **`simulator-watcher`** — polls every set (a set failing to list throws → the tick is skipped, so a transient error still can't mass-dispose tracked services); warms the map for the injection path. - **simulator-server blueprint** — passes `--device-set` (the same flag Radon IDE passes to this shared binary); default-set spawns unchanged. - **`ax-prefs`** — the pre-boot AX plist now lands under the owning set's device dir (previously hardcoded to the default `CoreSimulator/Devices` root, so the write would land nowhere for these devices and describe would degrade). - **`boot-device`** — set-aware shutdown/boot/bootstatus; skips the Simulator.app attach + `CurrentDeviceUDID` write for additional-set devices (one Simulator.app instance displays one device set — these run headless and are streamed, exactly how Radon IDE presents them). - **Every remaining per-device simctl call site**: launch-app / restart-app / reinstall-app (both the direct impls and the `SimctlBackend` shared handlers), open-url, settings-permissions (privacy + install probe), the tvOS screenshot fallback, status-bar pin/restore, device-shutdown, the ios-host `simctl spawn` family behind describe/native-devtools, tv-control's AX daemon, and the native profiler's sync helpers (cache warmed at `startNativeProfilerIos`). **Out of scope**: `sim-remote` — the remote orchestrator host has its own device-set story. ## Testing - **Unit**: new `ios-device-sets.test.ts` (mapping, lazy probe + caching semantics, no-probe fast path with no sets configured, discovery enumeration/tagging/dedup, missing-dir guard); blueprint test asserts the `--device-set` argv; the profiler shell-injection mock extended for the new import. Full tool-server suite green: **276 files / 2752 tests**; `tsc --build`, eslint, prettier clean. - **Live, end-to-end on a real Radon IDE device** (iPhone Air, iOS 26.4, in `com.swmansion.radon-ide/Devices/iOS`), driving this branch's tool-server over HTTP with the config set at project scope: - `list-devices` → the Radon device appears, tagged with its `deviceSet` (invisible to argent 0.16.1 on the same machine); - `boot-device` → boots headless in its set, no Simulator.app window; - `screenshot` → real home-screen PNG via simulator-server spawned with `--device-set`; - `launch-app` (Settings) → launched via `simctl --set launch`; - `describe` → full accessibility tree from the Settings screen, i.e. the AX bootstrap (set-aware prefs + in-sim ax-service daemon via `simctl --set spawn`) works; - clean shutdown; default-set devices meanwhile listed and behaved exactly as before.
153 lines
6.5 KiB
TypeScript
153 lines
6.5 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
|
|
// Records every child_process invocation the profiler makes so the test can
|
|
// assert how the (attacker-influenced) udid is passed and how stdout is buffered.
|
|
const rec = vi.hoisted(() => ({
|
|
calls: [] as {
|
|
fn: string;
|
|
file: string;
|
|
args: string[];
|
|
options?: { maxBuffer?: number; input?: string };
|
|
}[],
|
|
}));
|
|
|
|
vi.mock("child_process", () => ({
|
|
execFileSync: (
|
|
file: string,
|
|
args: string[],
|
|
options?: { maxBuffer?: number; input?: string }
|
|
) => {
|
|
rec.calls.push({ fn: "execFileSync", file, args, options });
|
|
if (args.includes("launchctl")) {
|
|
// `launchctl list` output: PID, status, label.
|
|
return "123\t0\tUIKitApplication:com.example.app[abc]\n";
|
|
}
|
|
if (args.includes("listapps")) {
|
|
return "(opaque simctl plist)";
|
|
}
|
|
if (args[0] === "-convert") {
|
|
// plutil JSON of installed apps, keyed arbitrarily.
|
|
return JSON.stringify({
|
|
"com.example.app": {
|
|
CFBundleExecutable: "Example",
|
|
CFBundleIdentifier: "com.example.app",
|
|
ApplicationType: "User",
|
|
},
|
|
});
|
|
}
|
|
return "";
|
|
},
|
|
// `promisify(execFile)`d at module load by ios-device-sets (imported through
|
|
// the profiler platform module). The sync helpers under test use the cached
|
|
// device-set view, so this async path must exist but is never called here.
|
|
execFile: (
|
|
_file: string,
|
|
_args: string[],
|
|
_options: unknown,
|
|
callback: (err: Error | null, result: { stdout: string; stderr: string }) => void
|
|
) => {
|
|
callback(null, { stdout: "", stderr: "" });
|
|
},
|
|
// A regression back to shell interpolation would route the command (with the
|
|
// udid baked into the string) through execSync — make that fail loudly.
|
|
execSync: (cmd: string) => {
|
|
rec.calls.push({ fn: "execSync", file: String(cmd), args: [] });
|
|
throw new Error(`execSync must not be used here (shell-injection risk): ${cmd}`);
|
|
},
|
|
spawn: () => {
|
|
throw new Error("spawn not expected in this test");
|
|
},
|
|
}));
|
|
|
|
import { enumerateRunningUserApps } from "../src/tools/profiler/native-profiler/platforms/ios";
|
|
import { DEFAULT_EXEC_MAX_BUFFER } from "../src/utils/ios-profiler/run-with-timeout";
|
|
import { deviceStrategy } from "../src/utils/ios-profiler/capture-strategy/device";
|
|
|
|
const VALID_UDID = "6DBF83B4-1A2B-4C3D-9E4F-0123456789AB";
|
|
|
|
describe("native iOS profiler: device_id shell-injection guard", () => {
|
|
it("passes a hostile udid only as a discrete argv element (never via a shell)", () => {
|
|
rec.calls.length = 0;
|
|
const hostileUdid = 'booted"; touch /tmp/argent-pwned #';
|
|
|
|
const apps = enumerateRunningUserApps(hostileUdid);
|
|
expect(apps).toHaveLength(1);
|
|
|
|
// No shell-interpreted execSync was used at all.
|
|
expect(rec.calls.every((c) => c.fn === "execFileSync")).toBe(true);
|
|
|
|
// simctl was invoked with the udid as an exact, standalone argv element —
|
|
// not concatenated into a command string a shell could parse.
|
|
const simctlCalls = rec.calls.filter((c) => c.args.includes("simctl"));
|
|
expect(simctlCalls.length).toBeGreaterThanOrEqual(2);
|
|
for (const c of simctlCalls) {
|
|
expect(c.file).toBe("xcrun");
|
|
expect(c.args).toContain(hostileUdid);
|
|
}
|
|
});
|
|
|
|
it("captures each stage's stdout with a raised maxBuffer (no ENOBUFS on large output)", () => {
|
|
// Splitting the old `simctl listapps | plutil` shell pipe into two
|
|
// in-process execFileSync stages means Node — not the OS — buffers each
|
|
// stdout. Without an explicit maxBuffer each stage inherits Node's 1 MiB
|
|
// default and a large `listapps` (many installed apps) throws ENOBUFS,
|
|
// surfaced as NATIVE_PROFILER_APP_LIST_FAILED. Every stage must lift the
|
|
// cap to run-with-timeout.ts's 256 MiB.
|
|
rec.calls.length = 0;
|
|
enumerateRunningUserApps(VALID_UDID);
|
|
|
|
const captures = rec.calls.filter((c) => c.fn === "execFileSync");
|
|
expect(captures.length).toBeGreaterThanOrEqual(3); // launchctl, listapps, plutil
|
|
for (const c of captures) {
|
|
expect(c.options?.maxBuffer).toBe(DEFAULT_EXEC_MAX_BUFFER);
|
|
}
|
|
});
|
|
|
|
it("feeds simctl listapps output to plutil over stdin (in-code pipe, not /bin/sh)", () => {
|
|
// The old `simctl listapps | plutil` relied on /bin/sh to wire the pipe.
|
|
// The replacement pipes in code: stage 1's stdout is handed to stage 2 via
|
|
// execFileSync's `input`, and `--` stops plutil option parsing so the
|
|
// trailing `-` (read-stdin) can never be taken for a flag. Guard both so a
|
|
// refactor can't quietly drop the stdin feed or the `--` and re-open a gap.
|
|
rec.calls.length = 0;
|
|
enumerateRunningUserApps(VALID_UDID);
|
|
|
|
const listapps = rec.calls.find((c) => c.args.includes("listapps"));
|
|
const plutil = rec.calls.find((c) => c.file === "plutil");
|
|
expect(listapps).toBeDefined();
|
|
expect(plutil).toBeDefined();
|
|
// Stage 2 receives stage 1's stdout via stdin (the mocked listapps returns
|
|
// "(opaque simctl plist)"), never on the argv or via a shell.
|
|
expect(plutil?.options?.input).toBe("(opaque simctl plist)");
|
|
const dashIdx = plutil?.args.indexOf("--") ?? -1;
|
|
expect(dashIdx).toBeGreaterThanOrEqual(0);
|
|
expect(plutil?.args[dashIdx + 1]).toBe("-");
|
|
});
|
|
});
|
|
|
|
describe("native iOS profiler: record-args device_id argv safety", () => {
|
|
it("places a hostile device_id as an exact standalone argv element after --device", () => {
|
|
// The record path (`xctrace record --device <deviceId> …`, spawned as argv)
|
|
// is where device_id — the input this fix is named for — reaches a
|
|
// subprocess. It is argv-safe by construction today (no shell), and the
|
|
// anchored UDID classifier keeps a metacharacter payload off the iOS path
|
|
// entirely; assert the token discipline anyway so a future {shell:true} or
|
|
// arg-fusion regression on the primary attacker input lights up.
|
|
const hostileDeviceId = 'booted"; touch /tmp/argent-pwned #';
|
|
const args = deviceStrategy.buildRecordArgs({
|
|
templatePath: "/tmp/Argent.tracetemplate",
|
|
deviceId: hostileDeviceId,
|
|
target: { executable: "Example", pid: 1234 },
|
|
outputFile: "/tmp/out.trace",
|
|
});
|
|
|
|
const deviceIdx = args.indexOf("--device");
|
|
expect(deviceIdx).toBeGreaterThanOrEqual(0);
|
|
expect(args[deviceIdx + 1]).toBe(hostileDeviceId);
|
|
// Exactly one element equals the value, and no other element embeds it —
|
|
// i.e. it was never fused with its flag or a neighbouring token.
|
|
expect(args.filter((a) => a === hostileDeviceId)).toHaveLength(1);
|
|
expect(args.some((a) => a !== hostileDeviceId && a.includes(hostileDeviceId))).toBe(false);
|
|
});
|
|
});
|