diff --git a/agents/agents.go b/agents/agents.go index 0c18774d..7006783f 100644 --- a/agents/agents.go +++ b/agents/agents.go @@ -22,10 +22,10 @@ var AndroidMobilecliDEX []byte //go:embed ios/agent-sim.dylib var IOSAgentSimDylib []byte -// IOSRealDeviceWebViewAgent is the Objective-C expression evaluated via LLDB to -// inject the webview agent into a foreground app on a real iOS device. It is an -// LLDB expression (top-level statements), not a standalone translation unit, so -// it is not compiled — only embedded. See devices/ios_device_webview.go. +// IOSAgentDevDylib is the real-device build of the compiled agent (server + +// bridge + dispatcher + network). It is mapped into a foreground app over the +// debugserver by devices/ios/machomap — no code signing, no re-signing — and +// supersedes the LLDB-evaluated webview source agent on real devices. // -//go:embed ios-real/agent.m -var IOSRealDeviceWebViewAgent string +//go:embed ios/agent-dev.dylib +var IOSAgentDevDylib []byte diff --git a/agents/android/java/InjectedAgent.java b/agents/android/java/InjectedAgent.java index 8e0a9522..c1a6d289 100644 --- a/agents/android/java/InjectedAgent.java +++ b/agents/android/java/InjectedAgent.java @@ -16,17 +16,35 @@ public class InjectedAgent implements JsonRpcSocketServer.Dispatcher { // entry point called by jvmti_agent.c once the app's main looper is up public static void start() { AndroidBridge.init(); + OkHttpHook.install(); AndroidBridge.sMainHandler.postDelayed(InjectedAgent::startRpcServer, 500); } private static void startRpcServer() { String socket = "mobilecli." + AndroidBridge.getPackageName(); - new JsonRpcSocketServer(socket, new InjectedAgent()).startDaemon(); + new JsonRpcSocketServer(socket, new InjectedAgent(), true).startDaemon(); } @Override public Object dispatch(String method, JSONObject params) throws Exception { switch (method) { + case "network.enable": + return Network.enable(params); + + case "network.poll": + return Network.poll(params); + + case "network.continue": + return Network.continueRequest(params); + + case "network.fulfill": + return Network.fulfill(params); + + case "network.abort": + return Network.abort(params); + + case "network.disable": + return Network.disable(params); case "device.dump.ui": return WebViews.dumpUi(); diff --git a/agents/android/java/JsonRpcSocketServer.java b/agents/android/java/JsonRpcSocketServer.java index b1d65b6f..c037ae6e 100644 --- a/agents/android/java/JsonRpcSocketServer.java +++ b/agents/android/java/JsonRpcSocketServer.java @@ -57,12 +57,18 @@ static String requireStringParam(JSONObject params, String key) throws RpcExcept private final String socketName; private final Dispatcher dispatcher; + private final boolean concurrent; private volatile boolean running = true; private LocalServerSocket serverSocket; JsonRpcSocketServer(String socketName, Dispatcher dispatcher) { + this(socketName, dispatcher, false); + } + + JsonRpcSocketServer(String socketName, Dispatcher dispatcher, boolean concurrent) { this.socketName = socketName; this.dispatcher = dispatcher; + this.concurrent = concurrent; } /** Binds and serves on the calling thread until stop() or a socket error. */ @@ -77,19 +83,26 @@ void start() throws Exception { if (running) Log.e(TAG, "accept failed on " + socketName, e); break; } - try { - handleConnection(conn); - } catch (Exception e) { - Log.e(TAG, "Error handling connection", e); - } finally { - try { - conn.close(); - } catch (Exception ignored) { - } + if (concurrent) { + Thread worker = new Thread(() -> serveConnection(conn), "jsonrpc-client-" + socketName); + worker.setDaemon(true); + worker.start(); + } else { + serveConnection(conn); } } } + private void serveConnection(LocalSocket conn) { + try { + handleConnection(conn); + } catch (Exception e) { + Log.e(TAG, "Error handling connection", e); + } finally { + try { conn.close(); } catch (Exception ignored) { } + } + } + /** Runs start() on a daemon thread; a bind failure surfaces as a log line there. */ void startDaemon() { Thread thread = new Thread(() -> { diff --git a/agents/android/java/Network.java b/agents/android/java/Network.java new file mode 100644 index 00000000..e2dd1501 --- /dev/null +++ b/agents/android/java/Network.java @@ -0,0 +1,249 @@ +package com.mobilenext.mobilecli; + +import android.util.Base64; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Pattern; + +/** Request routing and the fail-open handoff between an app thread and RPC threads. */ +final class Network { + private static final long REQUEST_TIMEOUT_MS = 60_000; + private static final long POLL_TIMEOUT_MS = 30_000; + private static final long POLL_GRACE_MS = 15_000; + private static final int MAX_EVENTS = 256; + private static final int MAX_BODY_BYTES = 256 * 1024; + private static final Object LOCK = new Object(); + private static final AtomicLong IDS = new AtomicLong(); + private static final Map PAUSED = new HashMap<>(); + private static final ArrayDeque EVENTS = new ArrayDeque<>(); + private static List routes = new ArrayList<>(); + private static boolean enabled; + private static int pollers; + private static long lastPollEnd; + + static final class Verdict { + final String action; + final JSONObject params; + private Verdict(String action, JSONObject params) { + this.action = action; + this.params = params; + } + } + + private static final Verdict UNCHANGED = new Verdict("continue", new JSONObject()); + + private static final class Pause { + Verdict verdict; + JSONObject event; + } + + private static final class Route { + final Pattern url; + final String method; + Route(Pattern url, String method) { + this.url = url; + this.method = method; + } + boolean matches(String requestUrl, String requestMethod) { + return (method == null || method.equalsIgnoreCase(requestMethod)) + && url.matcher(requestUrl).matches(); + } + } + + private Network() { } + + static JSONObject enable(JSONObject params) throws Exception { + JSONArray definitions = params == null ? null : params.optJSONArray("routes"); + if (definitions == null) { + throw new RpcException(RpcException.INVALID_PARAMS, "routes must be an array"); + } + List next = new ArrayList<>(); + for (int i = 0; i < definitions.length(); i++) { + JSONObject definition = definitions.optJSONObject(i); + if (definition == null) { + throw new RpcException(RpcException.INVALID_PARAMS, "route must be an object"); + } + String glob = JsonRpcSocketServer.requireParam(definition, "url"); + String method = definition.optString("method", null); + next.add(new Route(NetworkGlob.compile(glob), method == null || method.isEmpty() ? null : method)); + } + // An empty route list means intercept every request. + if (next.isEmpty()) { + next.add(new Route(Pattern.compile(".*", Pattern.DOTALL), null)); + } + synchronized (LOCK) { + releaseAllLocked(); + EVENTS.clear(); + routes = next; + enabled = true; + lastPollEnd = System.currentTimeMillis(); + LOCK.notifyAll(); + } + return ok(); + } + + static JSONObject poll(JSONObject params) throws JSONException { + JSONArray batch = new JSONArray(); + long deadline = System.currentTimeMillis() + POLL_TIMEOUT_MS; + synchronized (LOCK) { + pollers++; + try { + while (enabled && EVENTS.isEmpty()) { + long remaining = deadline - System.currentTimeMillis(); + if (remaining <= 0) { + break; + } + try { LOCK.wait(remaining); } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + while (!EVENTS.isEmpty()) batch.put(EVENTS.removeFirst()); + } finally { + pollers--; + lastPollEnd = System.currentTimeMillis(); + } + } + return new JSONObject().put("events", batch); + } + + static JSONObject continueRequest(JSONObject params) throws Exception { + return decide(params, "continue"); + } + + static JSONObject fulfill(JSONObject params) throws Exception { + int status = params == null ? 0 : params.optInt("status", 0); + if (status < 100 || status > 599) { + throw new RpcException(RpcException.INVALID_PARAMS, "status must be an HTTP status code"); + } + return decide(params, "fulfill"); + } + + static JSONObject abort(JSONObject params) throws Exception { + return decide(params, "abort"); + } + + static JSONObject disable(JSONObject params) throws JSONException { + synchronized (LOCK) { + enabled = false; + routes = new ArrayList<>(); + EVENTS.clear(); + releaseAllLocked(); + LOCK.notifyAll(); + } + return ok(); + } + + /** + * True if a request on this route would be paused. The interceptor checks this + * before reading the request body, so disabled or non-matching requests are not + * read at all — avoiding a needless body copy and a second writeTo that would + * corrupt a streaming upload whose body does not declare isOneShot/isDuplex. + */ + static boolean shouldPause(String method, String url) { + synchronized (LOCK) { + return enabled && matchesLocked(url, method) && EVENTS.size() < MAX_EVENTS; + } + } + + /** Called only by the OkHttp interceptor on the app's request thread. */ + static Verdict await(String method, String url, JSONObject headers, byte[] body) { + Pause pause = new Pause(); + String id; + synchronized (LOCK) { + if (!enabled || !matchesLocked(url, method) || EVENTS.size() >= MAX_EVENTS) { + return UNCHANGED; + } + id = Long.toString(IDS.incrementAndGet()); + JSONObject event = new JSONObject(); + try { + event.put("type", "paused"); + event.put("requestId", id); + event.put("method", method); + event.put("url", url); + event.put("headers", headers); + event.put("body", body == null ? JSONObject.NULL : Base64.encodeToString( + body, 0, Math.min(body.length, MAX_BODY_BYTES), Base64.NO_WRAP)); + if (body != null && body.length > MAX_BODY_BYTES) { + event.put("bodyTruncated", true); + } + event.put("stack", "okhttp"); + } catch (JSONException e) { + return UNCHANGED; + } + PAUSED.put(id, pause); + pause.event = event; + EVENTS.addLast(event); + LOCK.notifyAll(); + } + long deadline = System.currentTimeMillis() + REQUEST_TIMEOUT_MS; + synchronized (LOCK) { + while (pause.verdict == null) { + long now = System.currentTimeMillis(); + if (!enabled || now >= deadline || (pollers == 0 && now - lastPollEnd >= POLL_GRACE_MS)) { + pause.verdict = UNCHANGED; + break; + } + long remaining = Math.min(deadline - now, 1000); + try { LOCK.wait(remaining); } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + pause.verdict = UNCHANGED; + } + } + PAUSED.remove(id); + EVENTS.remove(pause.event); + return pause.verdict; + } + } + + private static JSONObject decide(JSONObject params, String action) throws Exception { + String id = JsonRpcSocketServer.requireParam(params, "requestId"); + // Check payload before waking the app thread. + if (params.has("body") && !params.isNull("body")) { + try { Base64.decode(params.getString("body"), Base64.DEFAULT); } + catch (IllegalArgumentException e) { + throw new RpcException(RpcException.INVALID_PARAMS, "body must be base64"); + } + } + synchronized (LOCK) { + Pause pause = PAUSED.get(id); + if (pause == null || pause.verdict != null) { + throw new RpcException(RpcException.INVALID_PARAMS, "request is not paused: " + id); + } + pause.verdict = new Verdict(action, params); + PAUSED.remove(id); + LOCK.notifyAll(); + } + return ok(); + } + + private static boolean matchesLocked(String url, String method) { + for (Route route : routes) { + if (route.matches(url, method)) { + return true; + } + } + return false; + } + + private static void releaseAllLocked() { + for (Pause pause : PAUSED.values()) { + pause.verdict = UNCHANGED; + } + PAUSED.clear(); + LOCK.notifyAll(); + } + + private static JSONObject ok() throws JSONException { + return new JSONObject().put("status", "ok"); + } +} diff --git a/agents/android/java/NetworkGlob.java b/agents/android/java/NetworkGlob.java new file mode 100644 index 00000000..fc817550 --- /dev/null +++ b/agents/android/java/NetworkGlob.java @@ -0,0 +1,31 @@ +package com.mobilenext.mobilecli; + +import java.util.regex.Pattern; + +/** Converts CLI URL globs to full-string matchers. Independent of Android. */ +final class NetworkGlob { + private NetworkGlob() { } + + static Pattern compile(String glob) { + StringBuilder regex = new StringBuilder(); + for (int i = 0; i < glob.length(); i++) { + char c = glob.charAt(i); + if (c == '*') { + if (i + 1 < glob.length() && glob.charAt(i + 1) == '*') { + regex.append(".*"); + i++; + } else { + regex.append("[^/]*"); + } + } else if (c == '?') { + regex.append('.'); + } else { + if ("\\.[]{}()+-^$|".indexOf(c) >= 0) { + regex.append('\\'); + } + regex.append(c); + } + } + return Pattern.compile(regex.toString(), Pattern.DOTALL); + } +} diff --git a/agents/android/java/OkHttpHook.java b/agents/android/java/OkHttpHook.java new file mode 100644 index 00000000..369b525e --- /dev/null +++ b/agents/android/java/OkHttpHook.java @@ -0,0 +1,274 @@ +package com.mobilenext.mobilecli; + +import android.util.Base64; +import android.util.Log; + +import org.json.JSONObject; + +import java.io.IOException; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Iterator; + +/** Reflective OkHttp interceptor so the injected dex has no OkHttp dependency. */ +final class OkHttpHook { + private static final String TAG = "OkHttpHook"; + private static volatile Object installedInterceptor; + + private OkHttpHook() { } + + static synchronized void install() { + if (installedInterceptor != null) { + return; + } + try { + Object app = Class.forName("android.app.ActivityThread") + .getMethod("currentApplication").invoke(null); + if (app == null) { + throw new IllegalStateException("application unavailable"); + } + ClassLoader loader = (ClassLoader) app.getClass().getMethod("getClassLoader").invoke(app); + Class clientClass = Class.forName("okhttp3.OkHttpClient", false, loader); + Class interceptorClass = Class.forName("okhttp3.Interceptor", false, loader); + Object interceptor = Proxy.newProxyInstance(loader, new Class[]{interceptorClass}, + new Handler()); + if (!nativeInstall(clientClass, interceptor)) { + throw new IllegalStateException("JVMTI getter breakpoint unavailable"); + } + installedInterceptor = interceptor; + Log.i(TAG, "OkHttp interceptor installed"); + } catch (ClassNotFoundException e) { + Log.i(TAG, "OkHttp is not present in the app"); + } catch (Exception | LinkageError e) { + Log.e(TAG, "Failed to install OkHttp interceptor", e); + } + } + + // The attached JVMTI library registers this native method before start(). + private static native boolean nativeInstall(Class clientClass, Object interceptor); + + private static final class Handler implements InvocationHandler { + @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + if (!"intercept".equals(method.getName())) { + if ("hashCode".equals(method.getName())) { + return System.identityHashCode(proxy); + } + if ("equals".equals(method.getName())) { + return proxy == args[0]; + } + return "mobilecli network interceptor"; + } + return intercept(args[0]); + } + } + + static Object intercept(Object chain) throws Throwable { + Object request = call(chain, "request"); + String url = call(request, "url").toString(); + String method = (String) call(request, "method"); + // Read the body only for requests that will actually pause. Reading it for + // every request wastes a copy and, for a streaming body that does not + // declare isOneShot/isDuplex, the later writeTo from proceed would send a + // truncated upload — corrupting the app's own request just because we attached. + if (!Network.shouldPause(method, url)) { + return call(chain, "proceed", request); + } + JSONObject headers = headersToJson(call(request, "headers")); + Network.Verdict verdict = Network.await(method, url, headers, requestBodyBytes(request)); + switch (verdict.action) { + case "fulfill": + return failOpenToIO(request, verdict, true); + case "abort": + throw new IOException("mobilecli network abort: " + + verdict.params.optString("errorCode", "failed")); + case "continue": + if (verdict.params.has("url") || verdict.params.has("method") + || verdict.params.has("headers") || verdict.params.has("body")) { + request = failOpenToIO(request, verdict, false); + } + return call(chain, "proceed", request); + default: + throw new IOException("unknown network decision: " + verdict.action); + } + } + + // A malformed host verdict (bad url/method/headers, GET with a body) makes the + // OkHttp builders throw IllegalArgumentException/JSONException. Those are not + // IOExceptions, so on an async call OkHttp lets them escape on the dispatcher + // thread and crash the app. Wrap them so the request fails cleanly instead. + private static Object failOpenToIO(Object request, Network.Verdict verdict, boolean fulfill) throws IOException { + try { + return fulfill ? syntheticResponse(request, verdict.params) + : overrideRequest(request, verdict.params); + } catch (IOException e) { + throw e; + } catch (Throwable e) { + throw new IOException("mobilecli network " + (fulfill ? "fulfill" : "continue") + + " failed: " + e.getMessage(), e); + } + } + + private static JSONObject headersToJson(Object headers) throws Throwable { + JSONObject result = new JSONObject(); + int size = ((Number) call(headers, "size")).intValue(); + for (int i = 0; i < size; i++) { + String name = (String) call(headers, "name", i); + String value = (String) call(headers, "value", i); + result.put(name, value); + } + return result; + } + + private static byte[] requestBodyBytes(Object request) { + try { + Object body = call(request, "body"); + if (body == null) { + return null; + } + long length = ((Number) call(body, "contentLength")).longValue(); + if (length < 0 || length > 256 * 1024) { + return null; + } + try { + if (Boolean.TRUE.equals(call(body, "isOneShot"))) { + return null; + } + } catch (NoSuchMethodException ignored) { + } + try { + if (Boolean.TRUE.equals(call(body, "isDuplex"))) { + return null; + } + } catch (NoSuchMethodException ignored) { + } + ClassLoader loader = body.getClass().getClassLoader(); + Class bufferClass = Class.forName("okio.Buffer", true, loader); + Object buffer = bufferClass.getConstructor().newInstance(); + call(body, "writeTo", buffer); + return (byte[]) call(buffer, "readByteArray"); + } catch (Throwable e) { + // Reading a body is observational. A custom RequestBody may reject a + // second write; leave it intact and report an unavailable body. + return null; + } + } + + private static Object overrideRequest(Object request, JSONObject params) throws Throwable { + Object builder = call(request, "newBuilder"); + if (params.has("url")) { + call(builder, "url", params.getString("url")); + } + if (params.has("headers")) { + JSONObject headers = params.getJSONObject("headers"); + Object headerBuilder = Class.forName("okhttp3.Headers$Builder", true, + request.getClass().getClassLoader()).getConstructor().newInstance(); + Iterator names = headers.keys(); + while (names.hasNext()) { + String name = names.next(); + call(headerBuilder, "add", name, headers.getString(name)); + } + call(builder, "headers", call(headerBuilder, "build")); + } + String method = params.optString("method", (String) call(request, "method")); + Object body = call(request, "body"); + if (params.has("body")) { + // Keep the original body's media type; otherwise OkHttp's bridge + // interceptor emits the request with no Content-Type and servers reject it. + Object mediaType = body == null ? null : call(body, "contentType"); + body = responseBody(request, Base64.decode(params.getString("body"), Base64.DEFAULT), true, mediaType); + } + if (params.has("method") || params.has("body")) { + call(builder, "method", method, body); + } + return call(builder, "build"); + } + + private static Object syntheticResponse(Object request, JSONObject params) throws Throwable { + ClassLoader loader = request.getClass().getClassLoader(); + Object builder = Class.forName("okhttp3.Response$Builder", true, loader) + .getConstructor().newInstance(); + call(builder, "request", request); + Class protocolClass = Class.forName("okhttp3.Protocol", true, loader); + @SuppressWarnings({"unchecked", "rawtypes"}) + Object http11 = Enum.valueOf((Class) protocolClass.asSubclass(Enum.class), "HTTP_1_1"); + call(builder, "protocol", http11); + int status = params.getInt("status"); + call(builder, "code", status); + call(builder, "message", "mobilecli synthetic response"); + JSONObject headers = params.optJSONObject("headers"); + if (headers != null) { + Iterator names = headers.keys(); + while (names.hasNext()) { + String name = names.next(); + call(builder, "addHeader", name, headers.getString(name)); + } + } + byte[] bytes = params.has("body") ? Base64.decode(params.getString("body"), Base64.DEFAULT) : new byte[0]; + call(builder, "body", responseBody(request, bytes, false, null)); + return call(builder, "build"); + } + + private static Object responseBody(Object request, byte[] bytes, boolean requestBody, Object mediaType) throws Throwable { + ClassLoader loader = request.getClass().getClassLoader(); + Class bodyClass = Class.forName(requestBody ? "okhttp3.RequestBody" : "okhttp3.ResponseBody", true, loader); + for (Method method : bodyClass.getMethods()) { + if (!method.getName().equals("create") || !java.lang.reflect.Modifier.isStatic(method.getModifiers())) { + continue; + } + Class[] types = method.getParameterTypes(); + if (types.length != 2) { + continue; + } + if (types[0] == byte[].class && types[1].getName().equals("okhttp3.MediaType")) { + return invoke(method, null, bytes, mediaType); + } + if (types[1] == byte[].class && types[0].getName().equals("okhttp3.MediaType")) { + return invoke(method, null, mediaType, bytes); + } + } + throw new IOException("unsupported OkHttp body factory"); + } + + private static Object call(Object target, String name, Object... args) throws Throwable { + for (Method method : target.getClass().getMethods()) { + if (!method.getName().equals(name) || method.getParameterTypes().length != args.length) { + continue; + } + Class[] types = method.getParameterTypes(); + boolean fits = true; + for (int i = 0; i < args.length; i++) { + if (args[i] != null && !boxed(types[i]).isInstance(args[i])) { + fits = false; + break; + } + } + if (fits) { + return invoke(method, target, args); + } + } + throw new NoSuchMethodException(target.getClass().getName() + "." + name); + } + + private static Class boxed(Class type) { + if (type == int.class) { + return Integer.class; + } + if (type == long.class) { + return Long.class; + } + if (type == boolean.class) { + return Boolean.class; + } + return type; + } + + private static Object invoke(Method method, Object target, Object... args) throws Throwable { + try { + return method.invoke(target, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } +} diff --git a/agents/android/jvmti_agent.c b/agents/android/jvmti_agent.c index 9fad4ee6..58582dd6 100644 --- a/agents/android/jvmti_agent.c +++ b/agents/android/jvmti_agent.c @@ -8,6 +8,76 @@ #define AGENT_CLASS "com.mobilenext.mobilecli.InjectedAgent" #define LOG(...) __android_log_print(ANDROID_LOG_DEBUG, TAG, __VA_ARGS__) +/* A breakpoint at OkHttpClient.interceptors() lets the agent replace the list + on both clients made before attach and clients made later. The getter then + returns that same field, with our interceptor appended once. */ +static jvmtiEnv *network_jvmti; +static jmethodID network_getter; +static jfieldID network_field; +static jobject network_interceptor; + +static void JNICALL network_breakpoint(jvmtiEnv *jvmti, JNIEnv *env, + jthread thread, jmethodID method, jlocation location) { + (void) location; + if (method != network_getter || network_interceptor == NULL) return; + jobject client = NULL; + jvmtiError local_err = (*jvmti)->GetLocalInstance(jvmti, thread, 0, &client); + if (local_err != JVMTI_ERROR_NONE || !client) { + LOG("OkHttp getter receiver unavailable: %d", local_err); + return; + } + jobject old_list = (*env)->GetObjectField(env, client, network_field); + if (!old_list || (*env)->ExceptionCheck(env)) goto done; + + jclass list_cls = (*env)->FindClass(env, "java/util/List"); + jmethodID contains = (*env)->GetMethodID(env, list_cls, "contains", "(Ljava/lang/Object;)Z"); + if ((*env)->CallBooleanMethod(env, old_list, contains, network_interceptor)) goto done; + if ((*env)->ExceptionCheck(env)) goto done; + + jclass array_cls = (*env)->FindClass(env, "java/util/ArrayList"); + jmethodID init = (*env)->GetMethodID(env, array_cls, "", "(Ljava/util/Collection;)V"); + jobject new_list = (*env)->NewObject(env, array_cls, init, old_list); + jmethodID add = (*env)->GetMethodID(env, array_cls, "add", "(Ljava/lang/Object;)Z"); + (*env)->CallBooleanMethod(env, new_list, add, network_interceptor); + if (!(*env)->ExceptionCheck(env)) { + (*env)->SetObjectField(env, client, network_field, new_list); + } + (*env)->DeleteLocalRef(env, new_list); +done: + if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + if (old_list) (*env)->DeleteLocalRef(env, old_list); + (*env)->DeleteLocalRef(env, client); +} + +static jboolean JNICALL network_install(JNIEnv *env, jclass unused, jclass client_cls, jobject interceptor) { + (void) unused; + if (!network_jvmti) return JNI_FALSE; + jmethodID getter = (*env)->GetMethodID(env, client_cls, "interceptors", "()Ljava/util/List;"); + jfieldID field = (*env)->GetFieldID(env, client_cls, "interceptors", "Ljava/util/List;"); + if (!getter || !field || (*env)->ExceptionCheck(env)) return JNI_FALSE; + jvmtiCapabilities caps = {0}; + caps.can_generate_breakpoint_events = 1; + caps.can_access_local_variables = 1; + jvmtiError err = (*network_jvmti)->AddCapabilities(network_jvmti, &caps); + if (err != JVMTI_ERROR_NONE) { LOG("network JVMTI capabilities failed: %d", err); return JNI_FALSE; } + if (network_getter) (*network_jvmti)->ClearBreakpoint(network_jvmti, network_getter, 0); + if (network_interceptor) (*env)->DeleteGlobalRef(env, network_interceptor); + network_interceptor = (*env)->NewGlobalRef(env, interceptor); + network_getter = getter; + network_field = field; + jvmtiEventCallbacks callbacks = {0}; + callbacks.Breakpoint = network_breakpoint; + err = (*network_jvmti)->SetEventCallbacks(network_jvmti, &callbacks, sizeof(callbacks)); + if (err == JVMTI_ERROR_NONE) err = (*network_jvmti)->SetBreakpoint(network_jvmti, getter, 0); + if (err == JVMTI_ERROR_NONE) err = (*network_jvmti)->SetEventNotificationMode( + network_jvmti, JVMTI_ENABLE, JVMTI_EVENT_BREAKPOINT, NULL); + if (err != JVMTI_ERROR_NONE) { + LOG("OkHttp getter breakpoint failed: %d", err); + return JNI_FALSE; + } + return JNI_TRUE; +} + /* ── load mobilecli.dex into the target process ─────────────────────────── */ /* dex_path — full path to mobilecli.dex on the device @@ -80,6 +150,14 @@ static void bootstrap(JNIEnv *env, const char *dex_path, const char *opt_dir) { return; } + jstring j_hook = (*env)->NewStringUTF(env, "com.mobilenext.mobilecli.OkHttpHook"); + jclass hook_cls = (jclass)(*env)->CallObjectMethod(env, loader, load, j_hook); + (*env)->DeleteLocalRef(env, j_hook); + if (hook_cls && !(*env)->ExceptionCheck(env)) { + JNINativeMethod methods[] = {{"nativeInstall", "(Ljava/lang/Class;Ljava/lang/Object;)Z", (void *)network_install}}; + if ((*env)->RegisterNatives(env, hook_cls, methods, 1) != JNI_OK) (*env)->ExceptionClear(env); + } else (*env)->ExceptionClear(env); + jmethodID start = (*env)->GetStaticMethodID(env, agentCls, "start", "()V"); if (!start || (*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); @@ -138,6 +216,11 @@ static jint setup(JavaVM *vm, const char *opts) { } JNIEnv *env = NULL; + + if ((*vm)->GetEnv(vm, (void **)&network_jvmti, JVMTI_VERSION_1_2) != JNI_OK) { + LOG("JVMTI environment unavailable"); + return JNI_ERR; + } jint rc = (*vm)->AttachCurrentThread(vm, (void **) &env, NULL); if (rc != JNI_OK || env == NULL) { LOG("AttachCurrentThread failed (rc=%d, dex=%s)", rc, dex_path); diff --git a/agents/ios-real/agent.m b/agents/ios-real/agent.m deleted file mode 100644 index ae411a1f..00000000 --- a/agents/ios-real/agent.m +++ /dev/null @@ -1,312 +0,0 @@ -@import Foundation; -// UIKit/WebKit class metadata avoided — calls cast result to silence LLDB strict mode -extern Class objc_getClass(const char *); -// Geometry helpers: UIKit headers aren't on the include path, so to call -// struct-returning methods (-bounds, -convertRect:toView:) we declare a -// CGRect-compatible struct and call through cast'd objc_msgSend (CGFloat is -// double on arm64; arm64 has no objc_msgSend_stret, so the plain symbol works). -extern void objc_msgSend(void); -extern SEL sel_registerName(const char *); -typedef double __mcfloat; -typedef struct { __mcfloat x, y; } __mcpoint; -typedef struct { __mcfloat width, height; } __mcsize; -typedef struct { __mcpoint origin; __mcsize size; } __mcrect; -// inline C declarations — LLDB remote-ios doesn't have SDK headers on include path -typedef unsigned int __socklen_t; -typedef unsigned short __in_port_t; -typedef unsigned int __in_addr_t; -struct __in_addr { __in_addr_t s_addr; }; -struct __sockaddr_in { - unsigned char sin_len; unsigned char sin_family; - __in_port_t sin_port; struct __in_addr sin_addr; char sin_zero[8]; -}; -struct __sockaddr { unsigned char sa_len; unsigned char sa_family; char sa_data[14]; }; -extern int socket(int, int, int); -extern int setsockopt(int, int, int, const void *, __socklen_t); -extern int bind(int, const struct __sockaddr *, __socklen_t); -extern int listen(int, int); -extern int accept(int, struct __sockaddr *, __socklen_t *); -extern long recv(int, void *, unsigned long, int); -extern long send(int, const void *, unsigned long, int); -extern int close(int); -extern int *__error(void); // Darwin errno is (*__error()) -extern __in_port_t htons(__in_port_t); -extern __in_addr_t htonl(__in_addr_t); -extern void *memset(void *, int, unsigned long); -extern char *strdup(const char *); -extern void free(void *); -extern unsigned long strlen(const char *); -#define __AF_INET 2 -#define __SOCK_STREAM 1 -#define __SOL_SOCKET 0xffff -#define __SO_REUSEADDR 0x0004 -#define __INADDR_LOOPBACK 0x7f000001UL -int __sfd = socket(__AF_INET, __SOCK_STREAM, 0); -int __opt = 1; -setsockopt(__sfd, __SOL_SOCKET, __SO_REUSEADDR, &__opt, sizeof(__opt)); -struct __sockaddr_in __sa; memset(&__sa, 0, sizeof(__sa)); -__sa.sin_family = __AF_INET; -__sa.sin_addr.s_addr = htonl(__INADDR_LOOPBACK); -int __port = 0; -__sa.sin_port = htons((unsigned short)12008); -if (bind(__sfd, (struct __sockaddr *)&__sa, sizeof(__sa)) == 0) { __port = 12008; } -if (__port > 0) { - listen(__sfd, 8); - int __srv = __sfd; - dispatch_async(dispatch_get_global_queue(0, 0), ^{ - id (^__findWV)(NSString *) = ^id(NSString *wvId) { - __block id found = nil; - id sem = dispatch_semaphore_create(0); - [[NSOperationQueue mainQueue] addOperationWithBlock:^{ - Class wk = NSClassFromString(@"WKWebView"); - Class wsCls = (Class)objc_getClass("UIWindowScene"); - id app = (id)[(Class)objc_getClass("UIApplication") sharedApplication]; - for (id sc in (NSArray *)[app connectedScenes]) - if ([(NSObject *)sc isKindOfClass:wsCls]) - for (id w in (NSArray *)[sc windows]) { - NSMutableArray *stk = [NSMutableArray arrayWithObject:w]; - while ([stk count]) { - id v = stk[0]; [stk removeObjectAtIndex:0]; - if (wk && [(NSObject *)v isKindOfClass:wk] && - [[NSString stringWithFormat:@"%p", v] isEqualToString:wvId]) - { found = v; break; } - [stk addObjectsFromArray:(NSArray *)[v subviews]]; - } - if (found) break; - } - dispatch_semaphore_signal(sem); - }]; - dispatch_semaphore_wait(sem, dispatch_time(0, 5000000000LL)); - return found; - }; - while (1) { - int cfd = accept(__srv, NULL, NULL); - if (cfd < 0) continue; - dispatch_async(dispatch_get_global_queue(0, 0), ^{ - NSMutableData *buf = [NSMutableData data]; - char tmp[4096]; long n; - NSData *crlf = [@"\r\n\r\n" dataUsingEncoding:NSASCIIStringEncoding]; - while ((n = recv(cfd, tmp, sizeof(tmp), 0)) > 0) { - [buf appendBytes:tmp length:(NSUInteger)n]; - NSRange sep = [buf rangeOfData:crlf options:0 range:NSMakeRange(0, buf.length)]; - if (sep.location != NSNotFound) { - NSString *hdr = [[NSString alloc] initWithData:[buf subdataWithRange:NSMakeRange(0, sep.location)] encoding:NSASCIIStringEncoding]; - NSInteger cl = 0; - for (NSString *__hdrLine in [hdr componentsSeparatedByString:@"\r\n"]) - if ([__hdrLine.lowercaseString hasPrefix:@"content-length:"]) - cl = [[__hdrLine substringFromIndex:15] integerValue]; - NSUInteger bs = sep.location + 4; - while ((NSInteger)(buf.length - bs) < cl && (n = recv(cfd, tmp, sizeof(tmp), 0)) > 0) - [buf appendBytes:tmp length:(NSUInteger)n]; - break; - } - } - NSRange hr = [buf rangeOfData:crlf options:0 range:NSMakeRange(0, buf.length)]; - NSData *body = (hr.location == NSNotFound) ? [NSData data] : - [buf subdataWithRange:NSMakeRange(hr.location + 4, buf.length - hr.location - 4)]; - NSDictionary *req = [NSJSONSerialization JSONObjectWithData:body options:0 error:nil]; - id rqId = req[@"id"] ?: [NSNull null]; - NSString *method = req[@"method"] ?: @""; - NSDictionary *params = req[@"params"]; - NSData *resp = nil; - if ([method isEqualToString:@"device.webview.list"]) { - __block NSMutableArray *wvs = [NSMutableArray array]; - id sem = dispatch_semaphore_create(0); - [[NSOperationQueue mainQueue] addOperationWithBlock:^{ - Class wk = NSClassFromString(@"WKWebView"); - Class wsCls = (Class)objc_getClass("UIWindowScene"); - id app = (id)[(Class)objc_getClass("UIApplication") sharedApplication]; - for (id sc in (NSArray *)[app connectedScenes]) - if ([(NSObject *)sc isKindOfClass:wsCls]) - for (id win in (NSArray *)[sc windows]) { - NSMutableArray *stk = [NSMutableArray arrayWithObject:win]; - while ([stk count]) { - id v = stk[0]; [stk removeObjectAtIndex:0]; - if (wk && [(NSObject *)v isKindOfClass:wk]) { - __mcrect (*__boundsMsg)(id, SEL) = (__mcrect (*)(id, SEL))objc_msgSend; - __mcrect (*__convMsg)(id, SEL, __mcrect, id) = (__mcrect (*)(id, SEL, __mcrect, id))objc_msgSend; - __mcrect __fr = __convMsg(v, sel_registerName("convertRect:toView:"), __boundsMsg(v, sel_registerName("bounds")), (id)0); - [wvs addObject:@{ - @"id": [NSString stringWithFormat:@"%p", v], - @"url": [(NSURL *)[v URL] absoluteString] ?: @"", - @"title": (NSString *)[v title] ?: @"", - @"bounds": @{@"x":@(__fr.origin.x),@"y":@(__fr.origin.y),@"width":@(__fr.size.width),@"height":@(__fr.size.height)}, - @"visible": @(!(BOOL)[v isHidden] && (id)[v window] != nil) - }]; - } - [stk addObjectsFromArray:(NSArray *)[v subviews]]; - } - } - dispatch_semaphore_signal(sem); - }]; - dispatch_semaphore_wait(sem, dispatch_time(0, 5000000000LL)); - resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"result":wvs} options:0 error:nil]; - } else if ([method isEqualToString:@"device.webview.goto"]) { - NSString *wvId = params[@"id"], *url = params[@"url"]; - id wv = (wvId && url) ? __findWV(wvId) : nil; - if (!wvId || !url) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32602),@"message":@"missing id or url"}} options:0 error:nil]; - else if (!wv) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32000),@"message":@"webview not found"}} options:0 error:nil]; - else { - id sem = dispatch_semaphore_create(0); - [[NSOperationQueue mainQueue] addOperationWithBlock:^{ (void)[wv loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:url]]]; dispatch_semaphore_signal(sem); }]; - dispatch_semaphore_wait(sem, dispatch_time(0, 5000000000LL)); - resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"result":@{@"status":@"ok"}} options:0 error:nil]; - } - } else if ([@[@"device.webview.reload",@"device.webview.goBack",@"device.webview.goForward"] containsObject:method]) { - NSString *wvId = params[@"id"]; - id wv = wvId ? __findWV(wvId) : nil; - if (!wvId) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32602),@"message":@"missing id"}} options:0 error:nil]; - else if (!wv) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32000),@"message":@"webview not found"}} options:0 error:nil]; - else { - id sem = dispatch_semaphore_create(0); - [[NSOperationQueue mainQueue] addOperationWithBlock:^{ - if ([method isEqualToString:@"device.webview.reload"]) (void)[wv reload]; - else if ([method isEqualToString:@"device.webview.goBack"]) (void)[wv goBack]; - else (void)[wv goForward]; - dispatch_semaphore_signal(sem); - }]; - dispatch_semaphore_wait(sem, dispatch_time(0, 5000000000LL)); - resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"result":@{@"status":@"ok"}} options:0 error:nil]; - } - } else if ([method isEqualToString:@"device.webview.evaluate"]) { - NSString *wvId = params[@"id"], *expr = params[@"expression"]; - id wv = (wvId && expr) ? __findWV(wvId) : nil; - if (!wvId || !expr) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32602),@"message":@"missing id or expression"}} options:0 error:nil]; - else if (!wv) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32000),@"message":@"webview not found"}} options:0 error:nil]; - else { - // A heap (non-tagged) value returned from evaluateJavaScript is - // over-released when WebKit's delivery pool drains, crashing the - // app — so we must never hold WebKit's result object. Instead the - // JS JSON-stringifies [ok, value]; inside the handler we copy the - // resulting bytes to C memory (no ObjC retain) and rebuild the - // response from our own bytes. Arrays + a mutable dict avoid the - // single-entry immutable dictionaries seen in the crash. - NSString *wrapped = [NSString stringWithFormat:@"(function(){try{return JSON.stringify([1,(function(){%@})()])}catch(e){return JSON.stringify([0,''+e])}})()", expr]; - __block char *jbuf = NULL; - id sem = dispatch_semaphore_create(0); - [[NSOperationQueue mainQueue] addOperationWithBlock:^{ - (void)[wv evaluateJavaScript:wrapped completionHandler:^(id r, NSError *e) { - if ([(NSObject *)r isKindOfClass:[NSString class]]) { const char *u = [(NSString *)r UTF8String]; if (u) jbuf = strdup(u); } - dispatch_semaphore_signal(sem); - }]; - }]; - dispatch_semaphore_wait(sem, dispatch_time(0, 30000000000LL)); - if (!jbuf) { - resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32000),@"message":@"no result from evaluate"}} options:0 error:nil]; - } else { - NSArray *parsed = [NSJSONSerialization JSONObjectWithData:[NSData dataWithBytes:jbuf length:strlen(jbuf)] options:0 error:nil]; - free(jbuf); - BOOL ok2 = [(NSObject *)parsed isKindOfClass:[NSArray class]] && [parsed count] == 2; - if (ok2 && [(NSNumber *)parsed[0] intValue] == 0) { - resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32000),@"message":parsed[1]}} options:0 error:nil]; - } else { - NSMutableDictionary *rd = [NSMutableDictionary dictionary]; - rd[@"result"] = ok2 ? parsed[1] : [NSNull null]; - resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"result":rd} options:0 error:nil]; - } - } - } - } else if ([method isEqualToString:@"device.webview.waitForLoadState"]) { - NSString *wvId = params[@"id"]; - id wv = wvId ? __findWV(wvId) : nil; - if (!wvId) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32602),@"message":@"missing id"}} options:0 error:nil]; - else if (!wv) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32000),@"message":@"webview not found"}} options:0 error:nil]; - else { - NSString *__lstate = params[@"state"] ?: @"load"; - NSInteger toMs = params[@"timeout"] ? [(NSNumber *)params[@"timeout"] integerValue] : 30000; - NSString *checkJS = [@"domcontentloaded" isEqualToString:__lstate] ? - @"return String(document.readyState==='interactive'||document.readyState==='complete')" : - @"return String(document.readyState==='complete')"; - NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:toMs / 1000.0]; - BOOL done = NO; - while (!done && [[NSDate date] compare:deadline] == NSOrderedAscending) { - __block id jsR = nil; - id sem2 = dispatch_semaphore_create(0); - NSString *wrapped = [NSString stringWithFormat:@"(function(){try{%@}catch(e){return null}})()", checkJS]; - [[NSOperationQueue mainQueue] addOperationWithBlock:^{ - (void)[wv evaluateJavaScript:wrapped completionHandler:^(id r, NSError *e) { jsR = r; dispatch_semaphore_signal(sem2); }]; - }]; - dispatch_semaphore_wait(sem2, dispatch_time(0, 5000000000LL)); - if ([@"true" isEqualToString:jsR]) done = YES; - else [NSThread sleepForTimeInterval:0.2]; - } - resp = done ? - [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"result":@{@"status":@"ok"}} options:0 error:nil] : - [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32000),@"message":@"timed out"}} options:0 error:nil]; - } - } else if ([method isEqualToString:@"device.flutter.vmServiceUri"]) { - // Return the running Flutter app's Dart VM service URI (with - // its auth token) by reading it in-process from the engine. - // Reached via the on-screen FlutterViewController -> engine -> - // its FlutterDartVMServicePublisher.url. `publisher` is a - // private ivar of FlutterEngine, read via KVC (the one - // version-sensitive point); `url` is a public property. A - // ClassNotFound / nil result means "not a Flutter app", which - // the caller treats as a signal to fall back to the a11y dump. - // (In-process mDNS was ruled out: DNS-SD needs C function- - // pointer callbacks an LLDB expression cannot define.) - __block NSString *uri = nil; - id sem = dispatch_semaphore_create(0); - [[NSOperationQueue mainQueue] addOperationWithBlock:^{ - @try { - Class fvcCls = NSClassFromString(@"FlutterViewController"); - Class wsCls = (Class)objc_getClass("UIWindowScene"); - id app = (id)[(Class)objc_getClass("UIApplication") sharedApplication]; - id fvc = nil; - for (id sc in (NSArray *)[app connectedScenes]) { - if (![(NSObject *)sc isKindOfClass:wsCls]) continue; - for (id win in (NSArray *)[sc windows]) { - NSMutableArray *stk = [NSMutableArray array]; - id root = [win rootViewController]; - if (root) [stk addObject:root]; - while ([stk count]) { - id vc = stk[0]; [stk removeObjectAtIndex:0]; - if (fvcCls && [(NSObject *)vc isKindOfClass:fvcCls]) { fvc = vc; break; } - NSArray *kids = (NSArray *)[vc childViewControllers]; - if (kids) [stk addObjectsFromArray:kids]; - id presented = [vc presentedViewController]; - if (presented) [stk addObject:presented]; - } - if (fvc) break; - } - if (fvc) break; - } - if (fvc) { - id engine = [fvc engine]; - id pub = engine ? [engine valueForKey:@"publisher"] : nil; - id url = pub ? [pub url] : nil; - if (url) uri = [(NSURL *)url absoluteString]; - } - } @catch (id __e) { uri = nil; } - dispatch_semaphore_signal(sem); - }]; - dispatch_semaphore_wait(sem, dispatch_time(0, 5000000000LL)); - if (uri) - resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"result":@{@"uri":uri}} options:0 error:nil]; - else - resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32602),@"message":@"not a flutter app"}} options:0 error:nil]; - } - if (!resp) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32601),@"message":[NSString stringWithFormat:@"method not found: %@", method]}} options:0 error:nil]; - if (!resp) resp = [@"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"internal error\"}}" dataUsingEncoding:NSUTF8StringEncoding]; - NSString *hdrs = [NSString stringWithFormat:@"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %lu\r\nConnection: close\r\n\r\n", (unsigned long)resp.length]; - NSData *hdrData = [hdrs dataUsingEncoding:NSASCIIStringEncoding]; - // send() can short-write (notably on EINTR); loop until the full - // header + body is written, advancing via unsigned char* arithmetic. - BOOL (^__sendAll)(const void *, unsigned long) = ^BOOL(const void *b, unsigned long n) { - const unsigned char *p = (const unsigned char *)b; - unsigned long left = n; - while (left > 0) { - long w = send(cfd, p, left, 0); - if (w > 0) { p += w; left -= (unsigned long)w; } - else if (w < 0 && (*__error()) == 4 /* EINTR */) { continue; } - else { return NO; } - } - return YES; - }; - if (__sendAll(hdrData.bytes, hdrData.length)) __sendAll(resp.bytes, resp.length); - close(cfd); - }); - } - }); -} -(int)__port diff --git a/agents/ios/Makefile b/agents/ios/Makefile index 1dd34dd6..df3b4e65 100644 --- a/agents/ios/Makefile +++ b/agents/ios/Makefile @@ -1,6 +1,6 @@ ARCH := arm64 MIN := 15.0 -SRCS := agent.m server.m bridge.m dispatcher.m +SRCS := agent.m server.m bridge.m dispatcher.m network.m network_glob.m FLAGS = -arch $(ARCH) \ -dynamiclib -fPIC -fobjc-arc \ @@ -8,12 +8,12 @@ FLAGS = -arch $(ARCH) \ all: agent-sim.dylib agent-dev.dylib -agent-sim.dylib: $(SRCS) server.h bridge.h dispatcher.h +agent-sim.dylib: $(SRCS) server.h bridge.h dispatcher.h network.h network_glob.h xcrun -sdk iphonesimulator clang $(FLAGS) \ -mios-simulator-version-min=$(MIN) \ -o $@ $(SRCS) -agent-dev.dylib: $(SRCS) server.h bridge.h dispatcher.h +agent-dev.dylib: $(SRCS) server.h bridge.h dispatcher.h network.h network_glob.h xcrun -sdk iphoneos clang $(FLAGS) \ -miphoneos-version-min=$(MIN) \ -o $@ $(SRCS) @@ -21,3 +21,9 @@ agent-dev.dylib: $(SRCS) server.h bridge.h dispatcher.h clean: rm -f agent-sim.dylib agent-dev.dylib + +test-glob: + clang -fobjc-arc -framework Foundation -o /tmp/mobilecli-network-glob-test network_glob.m test/network_glob.m + /tmp/mobilecli-network-glob-test + +.PHONY: test-glob diff --git a/agents/ios/agent.m b/agents/ios/agent.m index 43d2d759..c52970b0 100644 --- a/agents/ios/agent.m +++ b/agents/ios/agent.m @@ -1,6 +1,7 @@ #import #import "server.h" #import "dispatcher.h" +#import "network.h" static int mobilecli_port = 0; @@ -11,6 +12,7 @@ __attribute__((constructor)) static void on_load(void) { NSLog(@"[mobilecli] agent loaded"); + [MobileNetwork install]; MobileServer *server = [[MobileServer alloc] initWithHandler:^NSData *(NSData *body) { return dispatch_rpc(body); diff --git a/agents/ios/dispatcher.m b/agents/ios/dispatcher.m index 8b3221bd..019fcd28 100644 --- a/agents/ios/dispatcher.m +++ b/agents/ios/dispatcher.m @@ -1,5 +1,6 @@ #import "dispatcher.h" #import "bridge.h" +#import "network.h" static const int kRPCParseError = -32700; static const int kRPCMethodNotFound = -32601; @@ -40,6 +41,13 @@ NSString *method = req[@"method"]; NSDictionary *params = req[@"params"]; + if ([method hasPrefix:@"network."]) { + NSError *networkError = nil; + NSDictionary *value = [MobileNetwork perform:method params:params error:&networkError]; + if (networkError) return rpc_error(reqId, (int)networkError.code, networkError.localizedDescription); + return rpc_result(reqId, value); + } + if ([method isEqualToString:@"device.webview.list"]) { return rpc_result(reqId, [IosBridge listWebViews]); } diff --git a/agents/ios/network.h b/agents/ios/network.h new file mode 100644 index 00000000..712a6403 --- /dev/null +++ b/agents/ios/network.h @@ -0,0 +1,9 @@ +#import + +// Shared request state and URLSession hook for the simulator agent. +@interface MobileNetwork : NSObject ++ (void)install; ++ (nullable NSDictionary *)perform:(NSString * _Nonnull)method + params:(nullable NSDictionary *)params + error:(NSError * _Nullable * _Nullable)error; +@end diff --git a/agents/ios/network.m b/agents/ios/network.m new file mode 100644 index 00000000..209bcdc7 --- /dev/null +++ b/agents/ios/network.m @@ -0,0 +1,546 @@ +#import "network.h" +#import "network_glob.h" +#import + +static const NSTimeInterval kDecisionTimeout = 60; +static const NSTimeInterval kPollTimeout = 30; +static const NSTimeInterval kPollGrace = 15; +static const NSUInteger kMaxEvents = 256; +static const NSUInteger kMaxEventBody = 256 * 1024; +static NSString *const kHandledKey = @"mobilecli.network.handled"; +static NSString *const kErrorDomain = @"mobilecli.network"; + +@interface MobilePause : NSObject +@property (nonatomic, strong) NSDictionary *event; +@property (nonatomic, strong) NSDictionary *verdict; +@end +@implementation MobilePause +@end + +@interface MobileURLProtocol : NSURLProtocol +@property (atomic, assign) BOOL stopped; +@property (atomic, strong) NSURLSession *forwardSession; +@property (atomic, strong) NSURLSessionDataTask *forwardTask; +@end + +@interface MobileNetwork (Internals) ++ (BOOL)matches:(NSURLRequest *)request; ++ (nullable NSString *)pauseRequest:(NSURLRequest *)request; ++ (NSDictionary *)waitFor:(NSString *)requestId; ++ (void)cancel:(nullable NSString *)requestId; +@end + +static NSCondition *stateLock; +static NSMutableArray *routes; +static NSMutableArray *events; +static NSMutableDictionary *paused; +static BOOL enabled; +static NSUInteger sequence; +static NSInteger pollers; +static NSDate *lastPollEnd; +static const void *kDelegateCloneKey = &kDelegateCloneKey; +static const void *kIsDelegateCloneKey = &kIsDelegateCloneKey; + +static NSError *invalid(NSString *message) { + return [NSError errorWithDomain:kErrorDomain code:-32602 + userInfo:@{NSLocalizedDescriptionKey: message}]; +} + +static BOOL matchesLocked(NSURLRequest *request) { + if (!enabled || !request.URL) { + return NO; + } + NSString *url = request.URL.absoluteString; + NSString *method = request.HTTPMethod ?: @"GET"; + for (NSDictionary *route in routes) { + NSString *wanted = route[@"method"]; + if (wanted && [wanted caseInsensitiveCompare:method] != NSOrderedSame) { + continue; + } + NSRegularExpression *pattern = route[@"pattern"]; + if ([pattern numberOfMatchesInString:url options:0 range:NSMakeRange(0, url.length)] > 0) { + return YES; + } + } + return NO; +} + +static void releaseAllLocked(void) { + for (MobilePause *pause in paused.allValues) { + pause.verdict = @{@"action": @"continue"}; + } + [paused removeAllObjects]; + [stateLock broadcast]; +} + +@interface NSURLSessionConfiguration (MobileNetworkConfiguration) ++ (NSURLSessionConfiguration *)mobile_defaultSessionConfiguration; ++ (NSURLSessionConfiguration *)mobile_ephemeralSessionConfiguration; +@end + +static void addProtocol(NSURLSessionConfiguration *configuration) { + NSMutableArray *classes = [configuration.protocolClasses mutableCopy] ?: [NSMutableArray array]; + if (![classes containsObject:[MobileURLProtocol class]]) { + [classes insertObject:[MobileURLProtocol class] atIndex:0]; + configuration.protocolClasses = classes; + } +} + +@implementation NSURLSessionConfiguration (MobileNetworkConfiguration) ++ (NSURLSessionConfiguration *)mobile_defaultSessionConfiguration { + NSURLSessionConfiguration *configuration = [self mobile_defaultSessionConfiguration]; + addProtocol(configuration); + return configuration; +} ++ (NSURLSessionConfiguration *)mobile_ephemeralSessionConfiguration { + NSURLSessionConfiguration *configuration = [self mobile_ephemeralSessionConfiguration]; + addProtocol(configuration); + return configuration; +} +@end + +@interface NSURLSession (MobileNetworkTasks) +- (NSURLSessionDataTask *)mobile_dataTaskWithURL:(NSURL *)url + completionHandler:(void (^)(NSData *, NSURLResponse *, NSError *))completionHandler; +- (NSURLSessionDataTask *)mobile_dataTaskWithRequest:(NSURLRequest *)request + completionHandler:(void (^)(NSData *, NSURLResponse *, NSError *))completionHandler; +- (NSURLSessionDataTask *)mobile_dataTaskWithRequest:(NSURLRequest *)request; +@end + +// interceptClone returns a session whose configuration is copied from `session` +// — preserving its cookie storage, HTTPAdditionalHeaders, credential storage and +// timeouts — plus MobileURLProtocol, together with the original delegate and +// delegate queue. It is created once per session and reused. It returns nil when +// `session` is itself such a clone, so that clone's own tasks run normally (and +// are intercepted through the protocol already in its configuration). Routing a +// matched request through this clone, rather than a shared ephemeral session, +// keeps the app's session context (notably cookies) on intercepted requests. +static NSURLSession *interceptClone(NSURLSession *session) { + if ([objc_getAssociatedObject(session, kIsDelegateCloneKey) boolValue]) { + return nil; + } + NSURLSession *clone = objc_getAssociatedObject(session, kDelegateCloneKey); + if (clone) { + return clone; + } + @synchronized (session) { + clone = objc_getAssociatedObject(session, kDelegateCloneKey); + if (!clone) { + NSURLSessionConfiguration *configuration = [session.configuration copy]; + addProtocol(configuration); + clone = [NSURLSession sessionWithConfiguration:configuration + delegate:session.delegate delegateQueue:session.delegateQueue]; + objc_setAssociatedObject(clone, kIsDelegateCloneKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + objc_setAssociatedObject(session, kDelegateCloneKey, clone, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + } + return clone; +} + +@implementation NSURLSession (MobileNetworkTasks) +- (NSURLSessionDataTask *)mobile_dataTaskWithURL:(NSURL *)url + completionHandler:(void (^)(NSData *, NSURLResponse *, NSError *))completionHandler { + NSURLSession *clone = interceptClone(self); + if (clone && [MobileNetwork matches:[NSURLRequest requestWithURL:url]]) { + return [clone dataTaskWithURL:url completionHandler:completionHandler]; + } + return [self mobile_dataTaskWithURL:url completionHandler:completionHandler]; +} + +- (NSURLSessionDataTask *)mobile_dataTaskWithRequest:(NSURLRequest *)request + completionHandler:(void (^)(NSData *, NSURLResponse *, NSError *))completionHandler { + NSURLSession *clone = interceptClone(self); + if (clone && [MobileNetwork matches:request]) { + return [clone dataTaskWithRequest:request completionHandler:completionHandler]; + } + return [self mobile_dataTaskWithRequest:request completionHandler:completionHandler]; +} + +- (NSURLSessionDataTask *)mobile_dataTaskWithRequest:(NSURLRequest *)request { + NSURLSession *clone = interceptClone(self); + if (clone && [MobileNetwork matches:request]) { + return [clone dataTaskWithRequest:request]; + } + return [self mobile_dataTaskWithRequest:request]; +} +@end + +@implementation MobileNetwork ++ (void)initialize { + if (self != [MobileNetwork class]) { + return; + } + stateLock = [NSCondition new]; + routes = [NSMutableArray array]; + events = [NSMutableArray array]; + paused = [NSMutableDictionary dictionary]; + lastPollEnd = [NSDate date]; +} + ++ (void)install { + static dispatch_once_t once; + dispatch_once(&once, ^{ + [NSURLProtocol registerClass:[MobileURLProtocol class]]; + Class meta = object_getClass([NSURLSessionConfiguration class]); + method_exchangeImplementations( + class_getInstanceMethod(meta, @selector(defaultSessionConfiguration)), + class_getInstanceMethod(meta, @selector(mobile_defaultSessionConfiguration))); + method_exchangeImplementations( + class_getInstanceMethod(meta, @selector(ephemeralSessionConfiguration)), + class_getInstanceMethod(meta, @selector(mobile_ephemeralSessionConfiguration))); + method_exchangeImplementations( + class_getInstanceMethod([NSURLSession class], @selector(dataTaskWithURL:completionHandler:)), + class_getInstanceMethod([NSURLSession class], @selector(mobile_dataTaskWithURL:completionHandler:))); + method_exchangeImplementations( + class_getInstanceMethod([NSURLSession class], @selector(dataTaskWithRequest:completionHandler:)), + class_getInstanceMethod([NSURLSession class], @selector(mobile_dataTaskWithRequest:completionHandler:))); + method_exchangeImplementations( + class_getInstanceMethod([NSURLSession class], @selector(dataTaskWithRequest:)), + class_getInstanceMethod([NSURLSession class], @selector(mobile_dataTaskWithRequest:))); + NSLog(@"[mobilecli] URLSession network hook installed"); + }); +} + ++ (BOOL)matches:(NSURLRequest *)request { + [stateLock lock]; + BOOL match = matchesLocked(request); + [stateLock unlock]; + return match; +} + ++ (nullable NSString *)pauseRequest:(NSURLRequest *)request { + [stateLock lock]; + if (!matchesLocked(request) || events.count >= kMaxEvents) { + [stateLock unlock]; + return nil; + } + NSString *requestId = [NSString stringWithFormat:@"%lu", (unsigned long)++sequence]; + NSData *body = request.HTTPBody; + NSMutableDictionary *event = [@{ + @"type": @"paused", @"requestId": requestId, + @"method": request.HTTPMethod ?: @"GET", + @"url": request.URL.absoluteString ?: @"", + @"headers": request.allHTTPHeaderFields ?: @{}, + @"body": body ? [[body subdataWithRange:NSMakeRange(0, MIN(body.length, kMaxEventBody))] + base64EncodedStringWithOptions:0] : [NSNull null], + @"stack": @"urlsession", + } mutableCopy]; + if (body.length > kMaxEventBody) { + event[@"bodyTruncated"] = @YES; + } + MobilePause *pause = [MobilePause new]; + pause.event = event; + paused[requestId] = pause; + [events addObject:event]; + [stateLock broadcast]; + [stateLock unlock]; + return requestId; +} + ++ (NSDictionary *)waitFor:(NSString *)requestId { + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:kDecisionTimeout]; + [stateLock lock]; + MobilePause *pause = paused[requestId]; + while (pause && !pause.verdict) { + NSDate *now = [NSDate date]; + if (!enabled || [now compare:deadline] != NSOrderedAscending || + (pollers == 0 && [now timeIntervalSinceDate:lastPollEnd] >= kPollGrace)) { + pause.verdict = @{@"action": @"continue"}; + break; + } + [stateLock waitUntilDate:[NSDate dateWithTimeIntervalSinceNow:1]]; + } + NSDictionary *verdict = pause.verdict ?: @{@"action": @"continue"}; + [paused removeObjectForKey:requestId]; + if (pause.event) { + [events removeObject:pause.event]; + } + [stateLock unlock]; + return verdict; +} + ++ (void)cancel:(NSString *)requestId { + if (!requestId) { + return; + } + [stateLock lock]; + MobilePause *pause = paused[requestId]; + if (pause) { + pause.verdict = @{@"action": @"continue"}; + [paused removeObjectForKey:requestId]; + [events removeObject:pause.event]; + [stateLock broadcast]; + } + [stateLock unlock]; +} + ++ (nullable NSDictionary *)perform:(NSString *)method params:(NSDictionary *)params error:(NSError **)error { + if ([method isEqualToString:@"network.enable"]) { + id definitions = params[@"routes"]; + if (![definitions isKindOfClass:[NSArray class]]) { + if (error) { + *error = invalid(@"routes must be an array"); + } + return nil; + } + NSMutableArray *next = [NSMutableArray array]; + for (id definition in definitions) { + if (![definition isKindOfClass:[NSDictionary class]] || + ![definition[@"url"] isKindOfClass:[NSString class]] || + [definition[@"url"] length] == 0) { + if (error) { + *error = invalid(@"route.url must be a nonempty string"); + } + return nil; + } + NSRegularExpression *pattern = MobileCompileNetworkGlob(definition[@"url"]); + if (!pattern) { + if (error) { + *error = invalid(@"invalid route glob"); + } + return nil; + } + NSMutableDictionary *route = [@{@"pattern": pattern} mutableCopy]; + if ([definition[@"method"] isKindOfClass:[NSString class]] && [definition[@"method"] length]) { + route[@"method"] = definition[@"method"]; + } + [next addObject:route]; + } + if (!next.count) { + [next addObject:@{@"pattern": MobileCompileNetworkGlob(@"**")}]; + } + [stateLock lock]; + releaseAllLocked(); + [events removeAllObjects]; + routes = next; + enabled = YES; + lastPollEnd = [NSDate date]; + [stateLock broadcast]; + [stateLock unlock]; + return @{@"status": @"ok"}; + } + if ([method isEqualToString:@"network.poll"]) { + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:kPollTimeout]; + [stateLock lock]; + pollers++; + while (enabled && !events.count && [[NSDate date] compare:deadline] == NSOrderedAscending) { + [stateLock waitUntilDate:deadline]; + } + NSArray *batch = [events copy]; + [events removeAllObjects]; + pollers--; + lastPollEnd = [NSDate date]; + [stateLock unlock]; + return @{@"events": batch}; + } + if ([method isEqualToString:@"network.disable"]) { + [stateLock lock]; + enabled = NO; + [routes removeAllObjects]; + [events removeAllObjects]; + releaseAllLocked(); + [stateLock unlock]; + return @{@"status": @"ok"}; + } + if ([method isEqualToString:@"network.continue"] || + [method isEqualToString:@"network.fulfill"] || + [method isEqualToString:@"network.abort"]) { + NSString *requestId = params[@"requestId"]; + if (![requestId isKindOfClass:[NSString class]] || !requestId.length) { + if (error) { + *error = invalid(@"missing params.requestId"); + } + return nil; + } + if ([method isEqualToString:@"network.fulfill"]) { + NSInteger status = [params[@"status"] integerValue]; + if (status < 100 || status > 599) { + if (error) { + *error = invalid(@"status must be an HTTP status code"); + } + return nil; + } + } + if (params[@"body"] && ![[NSData alloc] initWithBase64EncodedString:params[@"body"] options:0]) { + if (error) { + *error = invalid(@"body must be base64"); + } + return nil; + } + NSString *action = [method substringFromIndex:[@"network." length]]; + [stateLock lock]; + MobilePause *pause = paused[requestId]; + if (!pause || pause.verdict) { + [stateLock unlock]; + if (error) { + *error = invalid([NSString stringWithFormat:@"request is not paused: %@", requestId]); + } + return nil; + } + pause.verdict = @{@"action": action, @"params": params}; + [stateLock broadcast]; + [stateLock unlock]; + return @{@"status": @"ok"}; + } + if (error) { + *error = [NSError errorWithDomain:kErrorDomain code:-32601 + userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"method not found: %@", method]}]; + } + return nil; +} +@end + +@implementation MobileURLProtocol { + NSString *_requestId; + NSURLRequest *_sourceRequest; +} + +// URLSession may turn an HTTPBody into a stream before URLProtocol sees it. +// Buffer small, known-length streams so the event can report the bytes and +// continue can replay exactly what was sent. Leave large streams untouched. +static NSURLRequest *materializeSmallBody(NSURLRequest *request) { + if (request.HTTPBody || !request.HTTPBodyStream) { + return request; + } + NSString *lengthText = request.allHTTPHeaderFields[@"Content-Length"]; + NSUInteger length = (NSUInteger)[lengthText longLongValue]; + if (!length || length > kMaxEventBody) { + return request; + } + NSInputStream *stream = request.HTTPBodyStream; + [stream open]; + NSMutableData *data = [NSMutableData dataWithCapacity:length]; + uint8_t buffer[8192]; + while (data.length < length) { + NSInteger count = [stream read:buffer maxLength:MIN(sizeof(buffer), length - data.length)]; + if (count <= 0) { + break; + } + [data appendBytes:buffer length:(NSUInteger)count]; + } + [stream close]; + if (data.length != length) { + return request; + } + NSMutableURLRequest *copy = [request mutableCopy]; + copy.HTTPBody = data; + return copy; +} + ++ (BOOL)canInitWithRequest:(NSURLRequest *)request { + if (![request.URL.scheme.lowercaseString isEqualToString:@"http"] && + ![request.URL.scheme.lowercaseString isEqualToString:@"https"]) { + return NO; + } + if ([NSURLProtocol propertyForKey:kHandledKey inRequest:request]) { + return NO; + } + return [MobileNetwork matches:request]; +} + ++ (BOOL)canInitWithTask:(NSURLSessionTask *)task { + return [self canInitWithRequest:task.currentRequest ?: task.originalRequest]; +} + ++ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request { return request; } + +- (void)startLoading { + _sourceRequest = materializeSmallBody(self.request); + _requestId = [MobileNetwork pauseRequest:_sourceRequest]; + if (!_requestId) { + [self forward:_sourceRequest]; + return; + } + dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + NSDictionary *verdict = [MobileNetwork waitFor:self->_requestId]; + if (self.stopped) { + return; + } + NSString *action = verdict[@"action"]; + NSDictionary *params = verdict[@"params"] ?: @{}; + if ([action isEqualToString:@"fulfill"]) { + [self fulfill:params]; + } else if ([action isEqualToString:@"abort"]) { + NSString *hint = params[@"errorCode"]; + NSInteger code = [hint isEqualToString:@"timedout"] ? NSURLErrorTimedOut : + [hint isEqualToString:@"connectionrefused"] ? NSURLErrorCannotConnectToHost : + NSURLErrorNetworkConnectionLost; + [self.client URLProtocol:self didFailWithError:[NSError errorWithDomain:NSURLErrorDomain + code:code userInfo:@{NSLocalizedDescriptionKey: hint ?: @"mobilecli network abort"}]]; + } else { + NSMutableURLRequest *request = [self->_sourceRequest mutableCopy]; + if ([params[@"url"] isKindOfClass:[NSString class]]) { + request.URL = [NSURL URLWithString:params[@"url"]]; + } + if ([params[@"method"] isKindOfClass:[NSString class]]) { + request.HTTPMethod = params[@"method"]; + } + if ([params[@"headers"] isKindOfClass:[NSDictionary class]]) { + request.allHTTPHeaderFields = params[@"headers"]; + } + if ([params[@"body"] isKindOfClass:[NSString class]]) { + request.HTTPBody = [[NSData alloc] initWithBase64EncodedString:params[@"body"] options:0]; + } + [self forward:request]; + } + }); +} + +- (void)forward:(NSURLRequest *)request { + if (self.stopped) { + return; + } + NSMutableURLRequest *marked = [request mutableCopy]; + [NSURLProtocol setProperty:@YES forKey:kHandledKey inRequest:marked]; + // Use the default (not ephemeral) configuration so the forwarded request + // carries the app's shared cookies and credentials and so Set-Cookie + // responses are persisted — a plain `continue` must not change what reaches + // the server. Remove MobileURLProtocol to avoid intercepting our own forward. + // (An NSURLProtocol cannot reach the originating session's own config, so a + // session with a private, non-shared cookie store is not covered here.) + NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration]; + NSMutableArray *classes = [configuration.protocolClasses mutableCopy] ?: [NSMutableArray array]; + [classes removeObject:[MobileURLProtocol class]]; + configuration.protocolClasses = classes; + self.forwardSession = [NSURLSession sessionWithConfiguration:configuration]; + __weak typeof(self) weakSelf = self; + self.forwardTask = [self.forwardSession dataTaskWithRequest:marked + completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + MobileURLProtocol *owner = weakSelf; + if (!owner || owner.stopped) { + return; + } + if (error) { + [owner.client URLProtocol:owner didFailWithError:error]; + } else { + [owner.client URLProtocol:owner didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed]; + if (data.length) { + [owner.client URLProtocol:owner didLoadData:data]; + } + [owner.client URLProtocolDidFinishLoading:owner]; + } + [owner.forwardSession finishTasksAndInvalidate]; + }]; + [self.forwardTask resume]; +} + +- (void)fulfill:(NSDictionary *)params { + NSData *body = [params[@"body"] isKindOfClass:[NSString class]] + ? [[NSData alloc] initWithBase64EncodedString:params[@"body"] options:0] : [NSData data]; + NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:self.request.URL + statusCode:[params[@"status"] integerValue] HTTPVersion:@"HTTP/1.1" + headerFields:[params[@"headers"] isKindOfClass:[NSDictionary class]] ? params[@"headers"] : @{}]; + [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed]; + if (body.length) { + [self.client URLProtocol:self didLoadData:body]; + } + [self.client URLProtocolDidFinishLoading:self]; +} + +- (void)stopLoading { + self.stopped = YES; + [MobileNetwork cancel:_requestId]; + [self.forwardTask cancel]; + [self.forwardSession invalidateAndCancel]; +} +@end diff --git a/agents/ios/network_glob.h b/agents/ios/network_glob.h new file mode 100644 index 00000000..b5894519 --- /dev/null +++ b/agents/ios/network_glob.h @@ -0,0 +1,4 @@ +#import + +// Playwright-style URL glob: * stops at '/', ** crosses '/', and ? matches one character. +NSRegularExpression * _Nullable MobileCompileNetworkGlob(NSString * _Nonnull glob); diff --git a/agents/ios/network_glob.m b/agents/ios/network_glob.m new file mode 100644 index 00000000..75923038 --- /dev/null +++ b/agents/ios/network_glob.m @@ -0,0 +1,27 @@ +#import "network_glob.h" + +NSRegularExpression *MobileCompileNetworkGlob(NSString *glob) { + NSMutableString *pattern = [NSMutableString stringWithString:@"^"]; + NSCharacterSet *meta = [NSCharacterSet characterSetWithCharactersInString:@"\\.[]{}()+-^$|"]; + for (NSUInteger i = 0; i < glob.length; i++) { + unichar c = [glob characterAtIndex:i]; + if (c == '*') { + if (i + 1 < glob.length && [glob characterAtIndex:i + 1] == '*') { + [pattern appendString:@".*"]; + i++; + } else { + [pattern appendString:@"[^/]*"]; + } + } else if (c == '?') { + [pattern appendString:@"."]; + } else { + if ([meta characterIsMember:c]) { + [pattern appendString:@"\\"]; + } + [pattern appendFormat:@"%C", c]; + } + } + [pattern appendString:@"$"]; + return [NSRegularExpression regularExpressionWithPattern:pattern + options:NSRegularExpressionDotMatchesLineSeparators error:nil]; +} diff --git a/agents/ios/test/network_glob.m b/agents/ios/test/network_glob.m new file mode 100644 index 00000000..507461ee --- /dev/null +++ b/agents/ios/test/network_glob.m @@ -0,0 +1,19 @@ +#import +#import "../network_glob.h" + +static BOOL matches(NSString *glob, NSString *url) { + NSRegularExpression *pattern = MobileCompileNetworkGlob(glob); + return [pattern numberOfMatchesInString:url options:0 range:NSMakeRange(0, url.length)] == 1; +} + +int main(void) { + @autoreleasepool { + NSCAssert(matches(@"**/todos/**", @"https://example.test/todos/1"), @"double star must cross slashes"); + NSCAssert(matches(@"https://*.test/todos/?", @"https://api.test/todos/1"), @"star and question mark"); + NSCAssert(!matches(@"https://*.test/todos/?", @"https://api.test/todos/12"), @"question mark is one character"); + NSCAssert(!matches(@"https://*.test/todos/?", @"https://api.test/deep/todos/1"), @"single star stops at slash"); + NSCAssert(matches(@"https://api.test/a.b", @"https://api.test/a.b"), @"dot is literal"); + NSCAssert(!matches(@"https://api.test/a.b", @"https://api.test/axb"), @"dot is not regex wildcard"); + } + return 0; +} diff --git a/cli/network.go b/cli/network.go new file mode 100644 index 00000000..df1a2eec --- /dev/null +++ b/cli/network.go @@ -0,0 +1,204 @@ +package cli + +import ( + "encoding/base64" + "fmt" + "os" + "strings" + + "github.com/mobile-next/mobilecli/commands" + "github.com/mobile-next/mobilecli/devices" + "github.com/spf13/cobra" +) + +var ( + networkPauses []string + networkRequestID string + networkURL string + networkMethod string + networkHeaders []string + networkBody string + networkStatus int + networkError string +) + +var networkCmd = &cobra.Command{ + Use: "network", + Short: "Capture and intercept the HTTP traffic of a debuggable app", + Long: `Observe and intercept the network calls of a debuggable app you own +(Android debuggable build, or iOS get-task-allow / Debug build). + +'network listen' streams a paused event for each request that matches a --pause +glob; answer it from another terminal (or a coprocess) with 'network continue', +'network fulfill', or 'network abort', keyed by the requestId in the event. +Requests that match no --pause are never touched, and a paused request you never +answer continues unchanged.`, +} + +var networkListenCmd = &cobra.Command{ + Use: "listen", + Short: "Stream paused network requests from a debuggable app", + Long: `Enables interception and streams one JSON event per matched request. +Press Ctrl+C to stop; stopping releases any request still paused. + +Each --pause is a URL glob, optionally prefixed with an HTTP method: + --pause '**/api/**' + --pause 'POST **/login' +Repeat --pause for multiple routes. With no --pause, every request is paused.`, + RunE: func(cmd *cobra.Command, args []string) error { + routes, err := parseNetworkRoutes(networkPauses) + if err != nil { + return err + } + return streamViaDaemon("cli.network.listen", commands.NetworkListenRequest{ + DeviceID: deviceId, + Routes: routes, + }) + }, +} + +var networkContinueCmd = &cobra.Command{ + Use: "continue", + Short: "Let a paused request proceed, optionally with overrides", + RunE: func(cmd *cobra.Command, args []string) error { + headers, err := parseNetworkHeaders(networkHeaders) + if err != nil { + return err + } + body, err := readNetworkBody(networkBody) + if err != nil { + return err + } + return runViaDaemon("cli.network.continue", commands.NetworkContinueRequest{ + DeviceID: deviceId, + RequestID: networkRequestID, + URL: networkURL, + Method: networkMethod, + Headers: headers, + Body: body, + }) + }, +} + +var networkFulfillCmd = &cobra.Command{ + Use: "fulfill", + Short: "Answer a paused request with a synthetic response", + RunE: func(cmd *cobra.Command, args []string) error { + headers, err := parseNetworkHeaders(networkHeaders) + if err != nil { + return err + } + body, err := readNetworkBody(networkBody) + if err != nil { + return err + } + return runViaDaemon("cli.network.fulfill", commands.NetworkFulfillRequest{ + DeviceID: deviceId, + RequestID: networkRequestID, + Status: networkStatus, + Headers: headers, + Body: body, + }) + }, +} + +var networkAbortCmd = &cobra.Command{ + Use: "abort", + Short: "Fail a paused request", + RunE: func(cmd *cobra.Command, args []string) error { + return runViaDaemon("cli.network.abort", commands.NetworkAbortRequest{ + DeviceID: deviceId, + RequestID: networkRequestID, + ErrorCode: networkError, + }) + }, +} + +// parseNetworkRoutes turns "--pause" values into routes. A value may be a bare +// URL glob, or "METHOD glob" to restrict to one method. +func parseNetworkRoutes(pauses []string) ([]devices.NetworkRoute, error) { + routes := make([]devices.NetworkRoute, 0, len(pauses)) + for _, p := range pauses { + p = strings.TrimSpace(p) + if p == "" { + continue + } + method := "" + glob := p + if fields := strings.SplitN(p, " ", 2); len(fields) == 2 && isHTTPMethod(fields[0]) { + method = strings.ToUpper(fields[0]) + glob = strings.TrimSpace(fields[1]) + } + if glob == "" { + return nil, fmt.Errorf("invalid --pause %q: empty url pattern", p) + } + routes = append(routes, devices.NetworkRoute{URL: glob, Method: method}) + } + return routes, nil +} + +func isHTTPMethod(s string) bool { + switch strings.ToUpper(s) { + case "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE", "CONNECT": + return true + } + return false +} + +// parseNetworkHeaders turns "--header K=V" values into a map. +func parseNetworkHeaders(headers []string) (map[string]string, error) { + if len(headers) == 0 { + return nil, nil + } + out := make(map[string]string, len(headers)) + for _, h := range headers { + k, v, ok := strings.Cut(h, "=") + if !ok || k == "" { + return nil, fmt.Errorf("invalid --header %q: expected key=value", h) + } + out[k] = v + } + return out, nil +} + +// readNetworkBody returns the base64 of the body. "@path" reads a file; anything +// else is taken literally. An empty value stays empty (no body override). +func readNetworkBody(body string) (string, error) { + if body == "" { + return "", nil + } + raw := []byte(body) + if strings.HasPrefix(body, "@") { + data, err := os.ReadFile(body[1:]) + if err != nil { + return "", fmt.Errorf("read --body file: %w", err) + } + raw = data + } + return base64.StdEncoding.EncodeToString(raw), nil +} + +func init() { + rootCmd.AddCommand(networkCmd) + networkCmd.AddCommand(networkListenCmd, networkContinueCmd, networkFulfillCmd, networkAbortCmd) + + networkListenCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to listen on") + networkListenCmd.Flags().StringArrayVar(&networkPauses, "pause", nil, "URL glob to pause on, optionally 'METHOD glob' (repeatable)") + + for _, c := range []*cobra.Command{networkContinueCmd, networkFulfillCmd, networkAbortCmd} { + c.Flags().StringVar(&deviceId, "device", "", "ID of the device") + c.Flags().StringVar(&networkRequestID, "id", "", "requestId of the paused request (required)") + _ = c.MarkFlagRequired("id") + } + + networkContinueCmd.Flags().StringVar(&networkURL, "url", "", "override the request URL") + networkContinueCmd.Flags().StringVar(&networkMethod, "method", "", "override the HTTP method") + networkContinueCmd.Flags().StringArrayVar(&networkHeaders, "header", nil, "override/add a header, key=value (repeatable)") + networkContinueCmd.Flags().StringVar(&networkBody, "body", "", "override the request body (literal, or @file)") + + networkFulfillCmd.Flags().IntVar(&networkStatus, "status", 200, "response status code") + networkFulfillCmd.Flags().StringArrayVar(&networkHeaders, "header", nil, "response header, key=value (repeatable)") + networkFulfillCmd.Flags().StringVar(&networkBody, "body", "", "response body (literal, or @file)") + + networkAbortCmd.Flags().StringVar(&networkError, "error", "", "failure code hint (e.g. failed, timedout)") +} diff --git a/commands/network.go b/commands/network.go new file mode 100644 index 00000000..c266472e --- /dev/null +++ b/commands/network.go @@ -0,0 +1,164 @@ +package commands + +import ( + "context" + "fmt" + + "github.com/mobile-next/mobilecli/devices" +) + +// ─── Request types ──────────────────────────────────────────── + +// NetworkListenRequest are the params for cli.network.listen (streaming). +type NetworkListenRequest struct { + DeviceID string `json:"deviceId"` + Routes []devices.NetworkRoute `json:"routes,omitempty"` +} + +// NetworkContinueRequest answers one paused request, letting it proceed +// (optionally with overrides). Body is base64. +type NetworkContinueRequest struct { + DeviceID string `json:"deviceId"` + RequestID string `json:"requestId"` + URL string `json:"url,omitempty"` + Method string `json:"method,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Body string `json:"body,omitempty"` +} + +// NetworkFulfillRequest answers one paused request with a synthetic response. +// Body is base64. +type NetworkFulfillRequest struct { + DeviceID string `json:"deviceId"` + RequestID string `json:"requestId"` + Status int `json:"status"` + Headers map[string]string `json:"headers,omitempty"` + Body string `json:"body,omitempty"` +} + +// NetworkAbortRequest fails one paused request. ErrorCode is an optional hint +// (e.g. "failed", "timedout", "connectionrefused"). +type NetworkAbortRequest struct { + DeviceID string `json:"deviceId"` + RequestID string `json:"requestId"` + ErrorCode string `json:"errorCode,omitempty"` +} + +// ─── Shared helper ──────────────────────────────────────────── + +func networkInterceptor(deviceID string) (devices.NetworkInterceptable, error) { + device, err := FindDeviceOrAutoSelect(deviceID) + if err != nil { + return nil, fmt.Errorf("error finding device: %w", err) + } + provider, ok := device.(devices.NetworkInterceptorProvider) + if !ok { + return nil, fmt.Errorf("network interception is not supported on %s (%s)", device.ID(), device.Platform()) + } + return provider.NetworkInterceptor() +} + +// ─── Commands ───────────────────────────────────────────────── + +// NetworkListenCommand enables interception, then relays each agent event to the +// client until the context is cancelled (the client disconnected / pressed +// Ctrl-C). On exit it disables interception, so any request still paused in the +// app continues unchanged — the fail-open guarantee. +func NetworkListenCommand(ctx context.Context, req NetworkListenRequest, notify func(any) error) *CommandResponse { + interceptor, err := networkInterceptor(req.DeviceID) + if err != nil { + return NewErrorResponse(err) + } + if err := interceptor.NetworkEnable(req.Routes); err != nil { + return NewErrorResponse(fmt.Errorf("enable network interception: %w", err)) + } + defer func() { _ = interceptor.NetworkDisable() }() + + for { + if ctx.Err() != nil { + return NewSuccessResponse(OK) + } + events, err := interceptor.NetworkPoll(ctx) + if err != nil { + if ctx.Err() != nil { + return NewSuccessResponse(OK) + } + return NewErrorResponse(fmt.Errorf("poll network events: %w", err)) + } + for _, event := range events { + if err := notify(event); err != nil { + // client went away: stop and let the deferred disable release the app + return NewSuccessResponse(OK) + } + } + } +} + +// NetworkContinueCommand lets a paused request proceed, with optional overrides. +func NetworkContinueCommand(req NetworkContinueRequest) *CommandResponse { + if req.RequestID == "" { + return NewErrorResponse(fmt.Errorf("requestId is required")) + } + interceptor, err := networkInterceptor(req.DeviceID) + if err != nil { + return NewErrorResponse(err) + } + params := map[string]any{"requestId": req.RequestID} + if req.URL != "" { + params["url"] = req.URL + } + if req.Method != "" { + params["method"] = req.Method + } + if len(req.Headers) > 0 { + params["headers"] = req.Headers + } + if req.Body != "" { + params["body"] = req.Body + } + if err := interceptor.NetworkContinue(params); err != nil { + return NewErrorResponse(err) + } + return NewSuccessResponse(OK) +} + +// NetworkFulfillCommand answers a paused request with a synthetic response. +func NetworkFulfillCommand(req NetworkFulfillRequest) *CommandResponse { + if req.RequestID == "" { + return NewErrorResponse(fmt.Errorf("requestId is required")) + } + interceptor, err := networkInterceptor(req.DeviceID) + if err != nil { + return NewErrorResponse(err) + } + params := map[string]any{"requestId": req.RequestID, "status": req.Status} + if len(req.Headers) > 0 { + params["headers"] = req.Headers + } + if req.Body != "" { + params["body"] = req.Body + } + if err := interceptor.NetworkFulfill(params); err != nil { + return NewErrorResponse(err) + } + return NewSuccessResponse(OK) +} + +// NetworkAbortCommand fails a paused request. +func NetworkAbortCommand(req NetworkAbortRequest) *CommandResponse { + if req.RequestID == "" { + return NewErrorResponse(fmt.Errorf("requestId is required")) + } + interceptor, err := networkInterceptor(req.DeviceID) + if err != nil { + return NewErrorResponse(err) + } + params := map[string]any{"requestId": req.RequestID} + if req.ErrorCode != "" { + params["errorCode"] = req.ErrorCode + } + if err := interceptor.NetworkAbort(params); err != nil { + return NewErrorResponse(err) + } + return NewSuccessResponse(OK) +} diff --git a/commands/registry.go b/commands/registry.go index f98850f2..12df1096 100644 --- a/commands/registry.go +++ b/commands/registry.go @@ -86,6 +86,9 @@ func CLIRegistry() map[string]CLIHandler { "cli.webview.wait": adapt(WebViewWaitForLoadStateCommand), "cli.webview.content": adapt(WebViewContentCommand), "cli.webview.query": adapt(WebViewQueryCommand), + "cli.network.continue": adapt(NetworkContinueCommand), + "cli.network.fulfill": adapt(NetworkFulfillCommand), + "cli.network.abort": adapt(NetworkAbortCommand), "cli.screenshot": adapt(ScreenshotCommand), "cli.dump.ui": adapt(DumpUICommand), "cli.url": adapt(URLCommand), diff --git a/devices/android_rpc.go b/devices/android_rpc.go index bf3652af..4bfe7720 100644 --- a/devices/android_rpc.go +++ b/devices/android_rpc.go @@ -47,6 +47,13 @@ func agentRequest(port int, method string, params any) (json.RawMessage, error) } func agentRequestWithTimeout(port int, method string, params any, timeout time.Duration) (json.RawMessage, error) { + return agentRequestContext(context.Background(), port, method, params, timeout) +} + +// agentRequestContext is agentRequestWithTimeout with a caller-supplied context, +// so a long call (e.g. network.poll's long-poll) aborts as soon as the context +// is cancelled instead of blocking until the agent replies or the timeout. +func agentRequestContext(ctx context.Context, port int, method string, params any, timeout time.Duration) (json.RawMessage, error) { payload, err := json.Marshal(jsonRPCRequest{JSONRPC: "2.0", ID: "1", Method: method, Params: params}) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) @@ -58,7 +65,7 @@ func agentRequestWithTimeout(port int, method string, params any, timeout time.D }() client := &http.Client{Timeout: timeout} - resp, err := postJSON(client, port, bytes.NewReader(payload)) + resp, err := postJSONContext(ctx, client, port, bytes.NewReader(payload)) if err != nil { if netErr := net.Error(nil); errors.As(err, &netErr) && netErr.Timeout() { return nil, fmt.Errorf("%w on port %d after %s: %v", errAgentTimedOut, port, timeout, err) @@ -100,7 +107,11 @@ func isAgentReady(port int) bool { } func postJSON(client *http.Client, port int, body io.Reader) (*http.Response, error) { - req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, fmt.Sprintf("http://localhost:%d/", port), body) + return postJSONContext(context.Background(), client, port, body) +} + +func postJSONContext(ctx context.Context, client *http.Client, port int, body io.Reader) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("http://localhost:%d/", port), body) if err != nil { return nil, err } diff --git a/devices/common.go b/devices/common.go index 406d13ba..5c69f30b 100644 --- a/devices/common.go +++ b/devices/common.go @@ -263,6 +263,42 @@ type WebViewable interface { WebViewWaitForLoadState(webviewID, state string, timeoutMs int) error } +// NetworkInterceptorProvider is implemented by devices that can attach a network +// interceptor to the debuggable foreground app. Resolving the interceptor +// injects/locates the in-app agent, so it can fail when no debuggable app is in +// front; callers resolve it once per operation. +type NetworkInterceptorProvider interface { + NetworkInterceptor() (NetworkInterceptable, error) +} + +// NetworkInterceptable observes and intercepts the HTTP traffic of the app the +// agent is attached to. It is a thin relay over the in-app agent's network.* +// RPC methods; see docs/network-interception.md for the contract. Everything is +// fail-open: a paused request the host never answers continues unchanged. +type NetworkInterceptable interface { + // NetworkEnable turns on interception for the given routes. A request is + // paused only when it matches a route, so unmatched traffic is untouched. + NetworkEnable(routes []NetworkRoute) error + // NetworkPoll long-polls the agent for the next batch of events, returning + // each event as an opaque JSON object to relay to the client. It returns an + // empty slice (no error) when the poll window elapses with nothing pending. + NetworkPoll(ctx context.Context) ([]json.RawMessage, error) + // NetworkContinue, NetworkFulfill and NetworkAbort answer one paused request, + // keyed by requestId inside params. See docs/network-interception.md. + NetworkContinue(params map[string]any) error + NetworkFulfill(params map[string]any) error + NetworkAbort(params map[string]any) error + // NetworkDisable turns interception off; any still-paused request continues. + NetworkDisable() error +} + +// NetworkRoute is one URL glob (and optional HTTP method) the host asked to +// pause on. An empty Method matches any method. +type NetworkRoute struct { + URL string `json:"url"` + Method string `json:"method,omitempty"` +} + // GetAllControllableDevices aggregates all known devices with options func GetAllControllableDevices(includeOffline bool) ([]ControllableDevice, error) { diff --git a/devices/ios/debugserver/LICENSE b/devices/ios/debugserver/LICENSE deleted file mode 100644 index 460ebad7..00000000 --- a/devices/ios/debugserver/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2019 danielpaulus - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/devices/ios/debugserver/gdbserver.go b/devices/ios/debugserver/gdbserver.go deleted file mode 100644 index 7adfbc20..00000000 --- a/devices/ios/debugserver/gdbserver.go +++ /dev/null @@ -1,100 +0,0 @@ -// Package debugserver implements the wire-level GDB Remote Serial Protocol -// client used to talk to the on-device debugserver (via the CoreDevice debug -// proxy). -// -// Vendored from github.com/danielpaulus/go-ios/ios/debugserver -// (only the GDBServer type from lockdownconn.go, which is dependency-free). -// The sole change from upstream is raising the bufio.Scanner buffer limit: -// debugserver answers jGetLoadedDynamicLibrariesInfos with the full loaded-image -// list as a single packet that easily exceeds bufio's default 64 KB token size, -// which made LLDB load zero images (no symbols → expr injection failed). -// Original code is MIT licensed — see the LICENSE file in this directory. -package debugserver - -import ( - "bufio" - "bytes" - "encoding/hex" - "errors" - "io" -) - -var ErrInvalidGDBServerPayload = errors.New("invalid payload") - -// maxPacketSize bounds a single GDB-RSP packet we will read from the device. -// The image-list response (jGetLoadedDynamicLibrariesInfos) for a real app is -// hundreds of KB to a few MB, so we allow up to 64 MB. -const maxPacketSize = 64 * 1024 * 1024 - -type GDBServer struct { - rw io.ReadWriter - scanner *bufio.Scanner -} - -// Implements wire level GDBServer protocol -func NewGDBServer(rw io.ReadWriter) *GDBServer { - scanner := bufio.NewScanner(rw) - // Raise the token limit; the default (bufio.MaxScanTokenSize, 64 KB) is far - // too small for the image-list packet and yields "token too long". - scanner.Buffer(make([]byte, 0, 64*1024), maxPacketSize) - scanner.Split(func(data []byte, atEOF bool) (int, []byte, error) { - const lenPacketSuffix = 3 // len("#00") - - if atEOF && len(data) == 0 { - return 0, nil, nil - } - - start := bytes.IndexRune(data, '$') - end := bytes.IndexRune(data, '#') - // Need more data - if start < 0 || end < 0 || len(data) < end+lenPacketSuffix { - return 0, nil, nil - } - // Invalid data - if end < start { - return 0, nil, ErrInvalidGDBServerPayload - } - - // Strip the $ prefix before returning - return end + lenPacketSuffix, data[start+1 : end], nil - }) - - return &GDBServer{ - rw: rw, - scanner: scanner, - } -} - -func (*GDBServer) chksum(pck string) string { - sum := 0 - for _, b := range pck { - sum += int(b) - } - return hex.EncodeToString([]byte{byte(sum % 256)}) -} - -func (g *GDBServer) formatPacket(pck string) string { - return "+$" + pck + "#" + g.chksum(pck) -} - -func (g *GDBServer) Recv() (string, error) { - if !g.scanner.Scan() { - return "", g.scanner.Err() - } - return g.scanner.Text(), nil -} - -func (g *GDBServer) Send(req string) error { - pck := g.formatPacket(req) - if _, err := g.rw.Write([]byte(pck)); err != nil { - return err - } - return nil -} - -func (g *GDBServer) Request(req string) (string, error) { - if err := g.Send(req); err != nil { - return "", err - } - return g.Recv() -} diff --git a/devices/ios/machomap/categories.go b/devices/ios/machomap/categories.go new file mode 100644 index 00000000..5b0cef6e --- /dev/null +++ b/devices/ios/machomap/categories.go @@ -0,0 +1,118 @@ +package machomap + +import ( + "encoding/binary" + "fmt" +) + +// categoryHelpers are the extra runtime functions needed only when the image has +// categories (resolved lazily so category-free dylibs don't require them). +var categoryHelpers = []string{"sel_registerName", "object_getClass", "class_addMethod"} + +// applyCategories registers each __objc_catlist category onto its target class. +// objc_readClassPair handles classes but not categories, and there is no public +// API to attach a category from a mapped image, so we read each category_t and +// its (small/relative) method lists out of the just-written image and add every +// method with class_addMethod — instance methods on the class, class methods on +// its metaclass. The target class is a bound framework class (e.g. NSURLSession), +// already realized, so adding methods is safe. Must run before the initializer, +// which may method-swizzle the methods the categories add. +func applyCategories(c *RSP, t *Target, base uint64, m *Macho, h map[string]uint64) (int, error) { + sec, ok := m.Sections["__objc_catlist"] + if !ok || sec.Size == 0 { + return 0, nil + } + selReg, getClass, addMethod := h["sel_registerName"], h["object_getClass"], h["class_addMethod"] + if selReg == 0 || getClass == 0 || addMethod == 0 { + return 0, fmt.Errorf("category helpers unresolved") + } + + applied := 0 + count := int(sec.Size / 8) + for i := 0; i < count; i++ { + // the catlist slot holds base+category_vmaddr after the image write + catAddr, err := c.readPtr(base + sec.Addr + uint64(i)*8) + if err != nil { + return applied, err + } + // category_t: name(+0) cls(+8) instanceMethods(+16) classMethods(+24) + clsAddr, err := c.readPtr(catAddr + 8) + if err != nil { + return applied, err + } + if clsAddr == 0 { + continue + } + for _, ml := range []struct { + fieldOff uint64 + isClass bool + }{{16, false}, {24, true}} { + listAddr, err := c.readPtr(catAddr + ml.fieldOff) + if err != nil { + return applied, err + } + if listAddr == 0 { + continue + } + target := clsAddr + if ml.isClass { + if target, err = t.Call(getClass, clsAddr); err != nil { // metaclass + return applied, err + } + } + n, err := applyMethodList(c, t, listAddr, target, selReg, addMethod) + if err != nil { + return applied, err + } + applied += n + } + } + return applied, nil +} + +// applyMethodList adds every method in a small (relative) method_list_t at +// listAddr to target. +func applyMethodList(c *RSP, t *Target, listAddr, target, selReg, addMethod uint64) (int, error) { + hdr, err := c.Memory(listAddr, 8) + if err != nil { + return 0, err + } + eaf := binary.LittleEndian.Uint32(hdr) + count := binary.LittleEndian.Uint32(hdr[4:]) + entsize := uint64(eaf & 0xFFFC) + if eaf&0x80000000 == 0 { + return 0, fmt.Errorf("non-relative method list (entsizeAndFlags=%#x) not supported", eaf) + } + applied := 0 + for k := uint32(0); k < count; k++ { + entry := listAddr + 8 + uint64(k)*entsize + rel, err := c.Memory(entry, 12) // {int32 name, int32 types, int32 imp}, each self-relative + if err != nil { + return applied, err + } + nameRel := int32(binary.LittleEndian.Uint32(rel)) + typesRel := int32(binary.LittleEndian.Uint32(rel[4:])) + impRel := int32(binary.LittleEndian.Uint32(rel[8:])) + nameSlot := relAddr(entry+0, nameRel) // -> selref slot (holds name cstring / SEL) + typesAddr := relAddr(entry+4, typesRel) // -> type-encoding cstring + impAddr := relAddr(entry+8, impRel) // -> IMP in our mapped __TEXT + namePtr, err := c.readPtr(nameSlot) + if err != nil { + return applied, err + } + sel, err := t.Call(selReg, namePtr) + if err != nil { + return applied, err + } + if _, err := t.Call(addMethod, target, sel, impAddr, typesAddr); err != nil { + return applied, err + } + applied++ + } + return applied, nil +} + +// relAddr adds a signed self-relative offset to a field address. +func relAddr(fieldAddr uint64, rel int32) uint64 { + return uint64(int64(fieldAddr) + int64(rel)) +} diff --git a/devices/ios/machomap/exporttrie.go b/devices/ios/machomap/exporttrie.go new file mode 100644 index 00000000..7015d554 --- /dev/null +++ b/devices/ios/machomap/exporttrie.go @@ -0,0 +1,343 @@ +package machomap + +import ( + "encoding/binary" + "fmt" + "os" + "strings" +) + +var trieDebug = os.Getenv("MACHOMAP_DEBUG") != "" + +// ExportTrieResolver resolves symbols with no host symbol files: it finds the +// relevant shared-cache dylibs in the target's address space (via +// dyld_all_image_infos) and parses each one's export trie out of device memory. +// This is what makes the mapper work on AWS Device Farm, where the Mac-only +// HostFilesResolver's Xcode symbol files do not exist. +type ExportTrieResolver struct { + c *RSP + nameDylib map[string]string // symbol name -> dylib path suffix that exports it + loadAddr map[string]uint64 // dylib path suffix -> in-memory load address (cache) +} + +// NewExportTrieResolver maps the bootstrap helpers to the dylibs that export them. +func NewExportTrieResolver(c *RSP) *ExportTrieResolver { + return &ExportTrieResolver{ + c: c, + nameDylib: map[string]string{ + "mmap": "/usr/lib/system/libsystem_kernel.dylib", + "mprotect": "/usr/lib/system/libsystem_kernel.dylib", + "socket": "/usr/lib/system/libsystem_kernel.dylib", + "bind": "/usr/lib/system/libsystem_kernel.dylib", + "listen": "/usr/lib/system/libsystem_kernel.dylib", + "__error": "/usr/lib/system/libsystem_kernel.dylib", + "dlsym": "/usr/lib/system/libdyld.dylib", + "sel_registerName": "/usr/lib/libobjc.A.dylib", + "objc_readClassPair": "/usr/lib/libobjc.A.dylib", + "objc_getClass": "/usr/lib/libobjc.A.dylib", + "objc_msgSend": "/usr/lib/libobjc.A.dylib", + "object_getClass": "/usr/lib/libobjc.A.dylib", + "class_addMethod": "/usr/lib/libobjc.A.dylib", + "class_getInstanceVariable": "/usr/lib/libobjc.A.dylib", + "ivar_getOffset": "/usr/lib/libobjc.A.dylib", + "class_getMethodImplementation": "/usr/lib/libobjc.A.dylib", + }, + loadAddr: map[string]uint64{}, + } +} + +// Resolve groups names by exporting dylib, locates each dylib once, and parses +// its export trie for the requested symbols. +func (r *ExportTrieResolver) Resolve(names []string) (map[string]uint64, error) { + byDylib := map[string][]string{} + for _, n := range names { + d, ok := r.nameDylib[n] + if !ok { + return nil, fmt.Errorf("export-trie resolver has no dylib mapping for %q", n) + } + byDylib[d] = append(byDylib[d], n) + } + // locate every needed dylib in one scan of the image list + var need []string + for d := range byDylib { + if _, ok := r.loadAddr[d]; !ok { + need = append(need, d) + } + } + if len(need) > 0 { + if err := r.locate(need); err != nil { + return nil, err + } + } + + out := map[string]uint64{} + for d, want := range byDylib { + base, ok := r.loadAddr[d] + if !ok || base == 0 { + return nil, fmt.Errorf("dylib %s not found in loaded images", d) + } + trie, err := r.readExportTrie(base) + if err != nil { + return nil, fmt.Errorf("%s export trie: %w", d, err) + } + for _, n := range want { + off, found, err := lookupExportTrie(trie, "_"+n) + if err != nil { + return nil, fmt.Errorf("%s: %w", n, err) + } + if !found { + return nil, fmt.Errorf("%q not exported by %s", n, d) + } + out[n] = base + off + } + } + return out, nil +} + +// locate scans dyld_all_image_infos.infoArray for each wanted dylib path suffix +// and records its in-memory load address. Stops once all are found. +func (r *ExportTrieResolver) locate(suffixes []string) error { + resp, err := r.c.Request("qShlibInfoAddr") + if err != nil { + return err + } + var aiaddr uint64 + if _, err := fmt.Sscanf(resp, "%x", &aiaddr); err != nil { + return fmt.Errorf("qShlibInfoAddr %q: %w", resp, err) + } + hdr, err := r.c.Memory(aiaddr, 16) + if err != nil { + return err + } + count := binary.LittleEndian.Uint32(hdr[4:]) + arrayPtr := binary.LittleEndian.Uint64(hdr[8:]) + if arrayPtr == 0 || count == 0 || count > 100000 { + return fmt.Errorf("implausible image info array: count=%d ptr=%#x", count, arrayPtr) + } + remaining := map[string]bool{} + for _, s := range suffixes { + remaining[s] = true + } + for i := uint32(0); i < count && len(remaining) > 0; i++ { + ent, err := r.c.Memory(arrayPtr+uint64(i)*24, 16) + if err != nil { + return err + } + loadAddr := binary.LittleEndian.Uint64(ent[0:]) + pathPtr := binary.LittleEndian.Uint64(ent[8:]) + if pathPtr == 0 { + continue + } + path := r.readCString(pathPtr, 256) + for s := range remaining { + if strings.HasSuffix(path, s) { + r.loadAddr[s] = loadAddr + delete(remaining, s) + } + } + } + if len(remaining) > 0 { + var miss []string + for s := range remaining { + miss = append(miss, s) + } + return fmt.Errorf("dylibs not found in image list: %v", miss) + } + return nil +} + +// readExportTrie reads a dylib's export trie from device memory by converting the +// LC_DYLD_EXPORTS_TRIE / LC_DYLD_INFO file offset into a mapped address using the +// image's own segment table (handles the shared cache's non-contiguous layout). +func (r *ExportTrieResolver) readExportTrie(loadAddr uint64) ([]byte, error) { + head, err := r.c.Memory(loadAddr, 32) + if err != nil { + return nil, err + } + if binary.LittleEndian.Uint32(head) != machoMagic64 { + return nil, fmt.Errorf("no Mach-O header at %#x", loadAddr) + } + ncmds := binary.LittleEndian.Uint32(head[16:]) + sizeofcmds := binary.LittleEndian.Uint32(head[20:]) + cmds, err := r.c.Memory(loadAddr+32, uint64(sizeofcmds)) + if err != nil { + return nil, err + } + + const ( + lcDyldInfo = 0x2 + lcDyldInfoOnly = 0x22 + lcDyldExportsTrie = 0x80000033 + ) + var textVM, leVM, leFileOff uint64 + var haveText, haveLE, haveTrie bool + var exportOff, exportSize uint64 + + off := 0 + for i := uint32(0); i < ncmds; i++ { + if off+8 > len(cmds) { + break + } + cmd := binary.LittleEndian.Uint32(cmds[off:]) + sz := binary.LittleEndian.Uint32(cmds[off+4:]) + if sz == 0 { + break + } + switch cmd { + case lcSegment64: + name := cstr16(cmds[off+8 : off+24]) + vmaddr := binary.LittleEndian.Uint64(cmds[off+24:]) + fileoff := binary.LittleEndian.Uint64(cmds[off+40:]) + if trieDebug { + vmsize := binary.LittleEndian.Uint64(cmds[off+32:]) + filesize := binary.LittleEndian.Uint64(cmds[off+48:]) + fmt.Fprintf(os.Stderr, "[seg] %-12s vmaddr=%#x vmsize=%#x fileoff=%#x filesize=%#x\n", + name, vmaddr, vmsize, fileoff, filesize) + } + switch name { + case "__TEXT": + textVM, haveText = vmaddr, true + case "__LINKEDIT": + leVM, leFileOff, haveLE = vmaddr, fileoff, true + } + case lcDyldExportsTrie: + // LC_DYLD_EXPORTS_TRIE is authoritative; a legacy LC_DYLD_INFO may also be + // present with a stale/tiny export_off, so this always wins. + exportOff = uint64(binary.LittleEndian.Uint32(cmds[off+8:])) + exportSize = uint64(binary.LittleEndian.Uint32(cmds[off+12:])) + haveTrie = true + if trieDebug { + fmt.Fprintf(os.Stderr, "[lc] LC_DYLD_EXPORTS_TRIE dataoff=%#x datasize=%#x\n", exportOff, exportSize) + } + case lcDyldInfo, lcDyldInfoOnly: + if !haveTrie { + exportOff = uint64(binary.LittleEndian.Uint32(cmds[off+40:])) + exportSize = uint64(binary.LittleEndian.Uint32(cmds[off+44:])) + } + if trieDebug { + fmt.Fprintf(os.Stderr, "[lc] LC_DYLD_INFO export_off=%#x export_size=%#x\n", + binary.LittleEndian.Uint32(cmds[off+40:]), binary.LittleEndian.Uint32(cmds[off+44:])) + } + } + off += int(sz) + } + if !haveText || !haveLE { + return nil, fmt.Errorf("missing __TEXT/__LINKEDIT in image at %#x", loadAddr) + } + if exportOff == 0 || exportSize == 0 { + return nil, fmt.Errorf("image at %#x has no export trie", loadAddr) + } + slide := loadAddr - textVM + trieAddr := leVM + slide + (exportOff - leFileOff) + if trieDebug { + fmt.Fprintf(os.Stderr, "[trie] load=%#x textVM=%#x leVM=%#x leFileOff=%#x exportOff=%#x size=%#x slide=%#x trieAddr=%#x\n", + loadAddr, textVM, leVM, leFileOff, exportOff, exportSize, slide, trieAddr) + } + trie, err := r.c.Memory(trieAddr, exportSize) + if err != nil { + return nil, err + } + if trieDebug { + n := len(trie) + if n > 48 { + n = 48 + } + fmt.Fprintf(os.Stderr, "[trie] first bytes: % x\n", trie[:n]) + } + return trie, nil +} + +func (r *ExportTrieResolver) readCString(addr uint64, max uint64) string { + data, err := r.c.Memory(addr, max) + if err != nil { + return "" + } + if i := indexByte(data, 0); i >= 0 { + return string(data[:i]) + } + return string(data) +} + +func indexByte(b []byte, c byte) int { + for i := range b { + if b[i] == c { + return i + } + } + return -1 +} + +// lookupExportTrie walks the dyld export trie for symbol and returns its address +// offset (from the image base) if found. It assumes a regular export (which the +// bootstrap symbols all are) and errors on re-exports and stub-resolvers rather +// than resolving them incorrectly. +func lookupExportTrie(trie []byte, symbol string) (uint64, bool, error) { + const ( + flagReexport = 0x08 + flagStubAndReso = 0x10 + ) + pos := 0 + rest := symbol + for { + if pos >= len(trie) { + return 0, false, nil + } + termSize, n := uleb(trie, pos) + pos += n + if rest == "" { + if termSize == 0 { + return 0, false, nil + } + flags, n2 := uleb(trie, pos) + p := pos + n2 + if flags&flagReexport != 0 || flags&flagStubAndReso != 0 { + return 0, false, fmt.Errorf("unsupported export kind (flags=%#x)", flags) + } + addr, _ := uleb(trie, p) + return addr, true, nil + } + // skip this node's terminal payload to reach the child list + childPos := pos + int(termSize) + if childPos >= len(trie) { + return 0, false, nil + } + childCount := int(trie[childPos]) + cp := childPos + 1 + next := -1 + for i := 0; i < childCount; i++ { + edgeStart := cp + for cp < len(trie) && trie[cp] != 0 { + cp++ + } + edge := string(trie[edgeStart:cp]) + cp++ // skip NUL + childOff, m := uleb(trie, cp) + cp += m + if next == -1 && strings.HasPrefix(rest, edge) { + rest = rest[len(edge):] + next = int(childOff) + } + } + if next == -1 { + return 0, false, nil + } + pos = next + } +} + +// uleb reads an unsigned LEB128 at data[pos], returning the value and bytes read. +func uleb(data []byte, pos int) (uint64, int) { + var result uint64 + var shift uint + i := 0 + for pos+i < len(data) { + b := data[pos+i] + result |= uint64(b&0x7f) << shift + i++ + if b&0x80 == 0 { + break + } + shift += 7 + } + return result, i +} diff --git a/devices/ios/machomap/fixups.go b/devices/ios/machomap/fixups.go new file mode 100644 index 00000000..2a9653c0 --- /dev/null +++ b/devices/ios/machomap/fixups.go @@ -0,0 +1,116 @@ +package machomap + +import ( + "encoding/binary" + "fmt" +) + +// Chained-fixup pointer formats we handle. Both reduce to "runtime address = +// load_base + target" because the dylib's preferred base is 0 (arm64, non-arm64e). +const ( + dyldChainedPtr64 = 2 // DYLD_CHAINED_PTR_64 + dyldChainedPtr64Offset = 6 // DYLD_CHAINED_PTR_64_OFFSET + chainedPtrStartNone = 0xFFFF +) + +// fixupSeg is one dyld_chained_starts_in_segment: where a segment's fixup chains +// begin and how to stride them. +type fixupSeg struct { + pageSize uint32 + ptrFormat uint16 + segmentOffset uint64 + pageStarts []uint16 +} + +// ParseChainedFixups returns the imported symbol names (in ordinal order), a +// parallel slice marking weak imports (which may legitimately resolve to 0), and +// the per-segment chain-start tables from the LC_DYLD_CHAINED_FIXUPS blob at +// hdrOff. +func ParseChainedFixups(data []byte, hdrOff uint32) (imports []string, weak []bool, segs []fixupSeg, err error) { + h := int(hdrOff) + if h+28 > len(data) { + return nil, nil, nil, fmt.Errorf("chained-fixups header out of range") + } + startsOff := binary.LittleEndian.Uint32(data[h+4:]) + impOff := binary.LittleEndian.Uint32(data[h+8:]) + symOff := binary.LittleEndian.Uint32(data[h+12:]) + impCnt := binary.LittleEndian.Uint32(data[h+16:]) + // imports format 1: {lib_ordinal:8, weak_import:1, name_offset:23} + for i := uint32(0); i < impCnt; i++ { + v := binary.LittleEndian.Uint32(data[h+int(impOff)+int(i)*4:]) + nameOff := (v >> 9) & 0x7FFFFF + name := cstrAt(data, h+int(symOff)+int(nameOff), len(data)) + imports = append(imports, name) + weak = append(weak, (v>>8)&1 == 1) + } + segCount := binary.LittleEndian.Uint32(data[h+int(startsOff):]) + for s := uint32(0); s < segCount; s++ { + so := binary.LittleEndian.Uint32(data[h+int(startsOff)+4+int(s)*4:]) + if so == 0 { + continue + } + b := h + int(startsOff) + int(so) + pageSize := binary.LittleEndian.Uint16(data[b+4:]) + ptrFormat := binary.LittleEndian.Uint16(data[b+6:]) + segmentOffset := binary.LittleEndian.Uint64(data[b+8:]) + pageCount := binary.LittleEndian.Uint16(data[b+20:]) + starts := make([]uint16, pageCount) + for p := 0; p < int(pageCount); p++ { + starts[p] = binary.LittleEndian.Uint16(data[b+22+p*2:]) + } + segs = append(segs, fixupSeg{ + pageSize: uint32(pageSize), + ptrFormat: ptrFormat, + segmentOffset: segmentOffset, + pageStarts: starts, + }) + } + return imports, weak, segs, nil +} + +// ApplyFixups patches the image buffer in place: rebases -> base+target+(high8<<56), +// binds -> importAddrs[ordinal]+addend. Each slot is read before it is overwritten +// so the chain's next-stride stays valid. Returns (rebases, binds). +func ApplyFixups(image []byte, segs []fixupSeg, base uint64, importAddrs []uint64) (int, int, error) { + rebases, binds := 0, 0 + for _, seg := range segs { + if seg.ptrFormat != dyldChainedPtr64 && seg.ptrFormat != dyldChainedPtr64Offset { + return 0, 0, fmt.Errorf("unsupported chained pointer format %d", seg.ptrFormat) + } + for pageIndex, start := range seg.pageStarts { + if start == chainedPtrStartNone { + continue + } + cur := seg.segmentOffset + uint64(pageIndex)*uint64(seg.pageSize) + uint64(start) + for { + if cur+8 > uint64(len(image)) { + return 0, 0, fmt.Errorf("fixup slot %#x out of image", cur) + } + v := binary.LittleEndian.Uint64(image[cur:]) + isBind := (v >> 63) & 1 + next := (v >> 51) & 0xFFF // stride to next fixup, in 4-byte units + var newval uint64 + if isBind == 1 { // {ordinal:24, addend:8, reserved:19, next:12, bind:1} + ordinal := v & 0xFFFFFF + addend := (v >> 24) & 0xFF + if ordinal >= uint64(len(importAddrs)) { + return 0, 0, fmt.Errorf("bind ordinal %d out of range", ordinal) + } + newval = importAddrs[ordinal] + addend + binds++ + } else { // {target:36, high8:8, reserved:7, next:12, bind:1} + target := v & 0xFFFFFFFFF + high8 := (v >> 36) & 0xFF + newval = base + target + (high8 << 56) + rebases++ + } + binary.LittleEndian.PutUint64(image[cur:], newval) + if next == 0 { + break + } + cur += next * 4 + } + } + } + return rebases, binds, nil +} diff --git a/devices/ios/machomap/macho.go b/devices/ios/machomap/macho.go new file mode 100644 index 00000000..a67ce9f8 --- /dev/null +++ b/devices/ios/machomap/macho.go @@ -0,0 +1,180 @@ +// Package machomap maps a precompiled arm64 Mach-O dylib into a running +// get-task-allow process over the on-device debugserver, with no code signing +// and no re-signing of the dylib or the app. The CS_DEBUGGED exemption (granted +// to a process an attached debugger controls) lets the debugger execute pages it +// wrote itself, so we can map, fix up, and run an unsigned dylib the same way a +// native loader would — the primitive mobilecli needs to run its instrumentation +// agent on real devices and on AWS Device Farm, where dlopen is impossible. +// +// This file parses the thin arm64 Mach-O: segments, symbols, sections, the +// offset-based initializer sections, and the offset of the chained-fixups blob. +// It is a direct port of the proven Python prototype (inject_map.py). +package machomap + +import ( + "encoding/binary" + "fmt" +) + +const ( + machoMagic64 = 0xFEEDFACF + lcSegment64 = 0x19 + lcSymtab = 0x02 + lcDyldChainedFixups = 0x80000034 + sInitFuncOffsets = 0x16 // section flags low byte: offsets (not pointers) to initializers +) + +// Segment is one LC_SEGMENT_64. InitProt's write bit decides which segments are +// remapped read-write after the whole image is reserved executable. +type Segment struct { + Name string + VMAddr uint64 + VMSize uint64 + FileOff uint64 + FileSize uint64 + InitProt int32 +} + +// Section records a section's vmaddr and size, keyed by name in Macho.Sections +// (e.g. __objc_classlist, __objc_selrefs, __objc_imageinfo). +type Section struct { + Addr uint64 + Size uint64 +} + +// InitSection is an S_INIT_FUNC_OFFSETS section: each uint32 is an offset from +// the image base to an initializer (the agent's C constructor lives here). +type InitSection struct { + Addr uint64 + Size uint64 +} + +// Macho is the parsed subset of a thin arm64 Mach-O the mapper needs. +type Macho struct { + Data []byte + Segments []Segment + Symbols map[string]uint64 // name -> n_value (unslid) + Sections map[string]Section + InitSections []InitSection + ChainedOff uint32 // file offset of the LC_DYLD_CHAINED_FIXUPS blob, 0 if none + HasChained bool +} + +// ParseMacho parses a thin arm64 64-bit Mach-O image. +func ParseMacho(data []byte) (*Macho, error) { + if len(data) < 32 || binary.LittleEndian.Uint32(data) != machoMagic64 { + return nil, fmt.Errorf("expected a thin arm64 64-bit Mach-O") + } + m := &Macho{ + Data: data, + Symbols: map[string]uint64{}, + Sections: map[string]Section{}, + } + ncmds := binary.LittleEndian.Uint32(data[16:]) + off := 32 + for i := uint32(0); i < ncmds; i++ { + if off+8 > len(data) { + return nil, fmt.Errorf("load command %d out of range", i) + } + cmd := binary.LittleEndian.Uint32(data[off:]) + size := binary.LittleEndian.Uint32(data[off+4:]) + if size == 0 || off+int(size) > len(data) { + return nil, fmt.Errorf("bad load command size at %d", off) + } + switch cmd { + case lcDyldChainedFixups: + m.ChainedOff = binary.LittleEndian.Uint32(data[off+8:]) + m.HasChained = true + case lcSegment64: + seg := Segment{ + Name: cstr16(data[off+8 : off+24]), + VMAddr: binary.LittleEndian.Uint64(data[off+24:]), + VMSize: binary.LittleEndian.Uint64(data[off+32:]), + FileOff: binary.LittleEndian.Uint64(data[off+40:]), + FileSize: binary.LittleEndian.Uint64(data[off+48:]), + InitProt: int32(binary.LittleEndian.Uint32(data[off+60:])), + } + m.Segments = append(m.Segments, seg) + nsects := binary.LittleEndian.Uint32(data[off+64:]) + soff := off + 72 + for s := uint32(0); s < nsects; s++ { + sectname := cstr16(data[soff : soff+16]) + addr := binary.LittleEndian.Uint64(data[soff+32:]) + ssize := binary.LittleEndian.Uint64(data[soff+40:]) + flags := binary.LittleEndian.Uint32(data[soff+64:]) + if flags&0xFF == sInitFuncOffsets { + m.InitSections = append(m.InitSections, InitSection{Addr: addr, Size: ssize}) + } + m.Sections[sectname] = Section{Addr: addr, Size: ssize} + soff += 80 + } + case lcSymtab: + symoff := binary.LittleEndian.Uint32(data[off+8:]) + nsyms := binary.LittleEndian.Uint32(data[off+12:]) + stroff := binary.LittleEndian.Uint32(data[off+16:]) + strsize := binary.LittleEndian.Uint32(data[off+20:]) + for n := uint32(0); n < nsyms; n++ { + base := int(symoff) + int(n)*16 + if base+16 > len(data) { + break + } + strx := binary.LittleEndian.Uint32(data[base:]) + value := binary.LittleEndian.Uint64(data[base+8:]) + name := cstrAt(data, int(stroff)+int(strx), int(stroff)+int(strsize)) + if name != "" { + m.Symbols[name] = value + } + } + } + off += int(size) + } + return m, nil +} + +// BuildImage lays the mapped segments out contiguously at their vmaddrs +// (__LINKEDIT is not mapped) and returns the image buffer, its size, and the +// mapped segments. +func (m *Macho) BuildImage() (image []byte, imageSize uint64, mapped []Segment) { + for _, s := range m.Segments { + if s.Name == "__LINKEDIT" { + continue + } + mapped = append(mapped, s) + if s.VMAddr+s.VMSize > imageSize { + imageSize = s.VMAddr + s.VMSize + } + } + image = make([]byte, imageSize) + for _, s := range mapped { + copy(image[s.VMAddr:s.VMAddr+s.FileSize], m.Data[s.FileOff:s.FileOff+s.FileSize]) + } + return image, imageSize, mapped +} + +// SectionCount returns the number of 8-byte pointer slots in a section (0 if +// the section is absent) — used for __objc_classlist / __objc_selrefs / etc. +func (m *Macho) SectionCount(name string) uint64 { + if s, ok := m.Sections[name]; ok { + return s.Size / 8 + } + return 0 +} + +func cstr16(b []byte) string { + n := 0 + for n < len(b) && b[n] != 0 { + n++ + } + return string(b[:n]) +} + +func cstrAt(data []byte, start, limit int) string { + if start < 0 || start >= len(data) || start >= limit { + return "" + } + end := start + for end < limit && end < len(data) && data[end] != 0 { + end++ + } + return string(data[start:end]) +} diff --git a/devices/ios/machomap/macho_test.go b/devices/ios/machomap/macho_test.go new file mode 100644 index 00000000..d1934e48 --- /dev/null +++ b/devices/ios/machomap/macho_test.go @@ -0,0 +1,83 @@ +package machomap + +import ( + "encoding/hex" + "os" + "testing" +) + +// theGoParserReproducesThePythonPrototypeCounts loads the real network agent +// dylib and checks that the ported Mach-O/fixup logic yields exactly the numbers +// the proven Python prototype (inject_map.py) printed on-device: +// +// image_size=0x1c000 rebases=483 binds=244 imports=97 +// selrefs=184 classes=5 categories=2 initializers=1 +// +// Set AGENT_DYLIB to override the path (default /tmp/agent-dev.dylib). The test +// skips if the dylib is not present so CI without it stays green. +func TestGoParserMatchesPythonCounts(t *testing.T) { + path := os.Getenv("AGENT_DYLIB") + if path == "" { + path = "/tmp/agent-dev.dylib" + } + data, err := os.ReadFile(path) + if err != nil { + t.Skipf("agent dylib not available (%v); set AGENT_DYLIB to run", err) + } + + m, err := ParseMacho(data) + if err != nil { + t.Fatalf("ParseMacho: %v", err) + } + image, imageSize, _ := m.BuildImage() + if imageSize != 0x1c000 { + t.Errorf("image size = %#x, want 0x1c000", imageSize) + } + if !m.HasChained { + t.Fatal("expected chained fixups") + } + + imports, _, segs, err := ParseChainedFixups(data, m.ChainedOff) + if err != nil { + t.Fatalf("ParseChainedFixups: %v", err) + } + if len(imports) != 97 { + t.Errorf("imports = %d, want 97", len(imports)) + } + // dummy import addresses just to exercise the walker and count rebases/binds + rebases, binds, err := ApplyFixups(image, segs, 0x100000000, make([]uint64, len(imports))) + if err != nil { + t.Fatalf("ApplyFixups: %v", err) + } + if rebases != 483 { + t.Errorf("rebases = %d, want 483", rebases) + } + if binds != 244 { + t.Errorf("binds = %d, want 244", binds) + } + + if got := m.SectionCount("__objc_selrefs"); got != 184 { + t.Errorf("selrefs = %d, want 184", got) + } + if got := m.SectionCount("__objc_classlist"); got != 5 { + t.Errorf("classes = %d, want 5", got) + } + if got := m.SectionCount("__objc_catlist"); got != 2 { + t.Errorf("categories = %d, want 2", got) + } + if len(m.InitSections) != 1 || m.InitSections[0].Size != 4 { + t.Errorf("init sections = %+v, want one of size 4", m.InitSections) + } +} + +// TestStubBytesDecode guards the embedded stub machine code: length and the +// first instruction (stp x19,x20,[sp,#-64]! = 0xa9bc53f3) must be intact. +func TestStubBytesDecode(t *testing.T) { + if len(StubBytes) != 256 { + t.Fatalf("stub bytes = %d, want 256", len(StubBytes)) + } + first := hex.EncodeToString(StubBytes[:4]) + if first != "f353bca9" { + t.Errorf("first stub word = %s, want f353bca9", first) + } +} diff --git a/devices/ios/machomap/mapper.go b/devices/ios/machomap/mapper.go new file mode 100644 index 00000000..b7ec41ff --- /dev/null +++ b/devices/ios/machomap/mapper.go @@ -0,0 +1,333 @@ +package machomap + +import ( + "encoding/binary" + "fmt" +) + +// mmap/mprotect protection and flag bits, and the W^X-driven mapping strategy: +// iOS fixes a mapping's max_protection at mmap time and forbids a page that is +// both writable and executable, so we reserve the whole image r-x (executable) +// and MAP_FIXED-remap the writable segments rw; the debugger writes the r-x code +// pages itself. +const ( + protRead = 1 + protWrite = 2 + protExec = 4 + vmProtWrite = 2 + mapPrivate = 0x0002 + mapAnon = 0x1000 + mapFixed = 0x0010 + neg1 = ^uint64(0) // -1 (mmap fd, and RTLD_DEFAULT base below) + rtldDefault = ^uint64(0) - 1 + + scratchSize = 0x10000 // rw scratch for staged strings/tables + stubPageSize = 0x4000 // r-x page holding the loop stubs + landing pad + // landingPadOffset is the call-return breakpoint address within the stub + // page, placed well past the stub bytes so callee code never executes it. + landingPadOffset = 0x800 + defaultWriteChunk = 1024 +) + +// bootstrapNames are the runtime helpers the mapper itself needs; the Resolver +// supplies their in-target addresses without any remote round-trips. +var bootstrapNames = []string{"mmap", "mprotect", "dlsym", "sel_registerName", "objc_readClassPair"} + +// Resolver resolves symbol names to their runtime addresses in the target +// process. Two implementations exist: host Xcode symbol files + shared-cache +// slide (fast, Mac-only — for parity testing), and an in-memory export-trie +// reader (host-independent — for AWS Device Farm). +type Resolver interface { + Resolve(names []string) (map[string]uint64, error) +} + +// Options controls the map. +type Options struct { + RunInit bool // run the dylib's offset-based initializers (starts the agent) + WriteChunk int // M-packet chunk size; 0 => defaultWriteChunk +} + +// Result reports what was mapped. +type Result struct { + Base uint64 + ImageSize uint64 + Rebases int + Binds int + Imports int + Selrefs int + Classes int + Categories int + Unresolved []string +} + +// Map maps dylib into the already-attached process behind c and executes its +// initializers (unless Options.RunInit is false). The process must be attached +// (QSetDetachOnError + vAttach) by the caller; Map borrows a thread and restores +// it before returning. +func Map(c *RSP, dylib []byte, resolver Resolver, opt Options) (*Result, error) { + m, err := ParseMacho(dylib) + if err != nil { + return nil, err + } + image, imageSize, mapped := m.BuildImage() + + helpers, err := resolver.Resolve(bootstrapNames) + if err != nil { + return nil, fmt.Errorf("resolve bootstrap helpers: %w", err) + } + for _, n := range bootstrapNames { + if helpers[n] == 0 { + return nil, fmt.Errorf("bootstrap helper %q unresolved", n) + } + } + + t, err := NewTarget(c) + if err != nil { + return nil, err + } + defer func() { _ = t.Restore() }() + + chunk := opt.WriteChunk + if chunk == 0 { + chunk = defaultWriteChunk + } + s := &mapSession{ + c: c, t: t, m: m, dylib: dylib, image: image, chunk: chunk, + helpers: helpers, resolver: resolver, + result: &Result{ImageSize: imageSize}, + } + + steps := []func() error{ + func() error { return s.reserveImage(imageSize) }, + func() error { return s.remapWritableSegments(mapped) }, + s.setupScratchAndStubs, + s.bindImports, + s.writeImage, + s.registerClasses, + s.applyImageCategories, + func() error { + if opt.RunInit { + return s.runInitializers() + } + return nil + }, + } + for _, step := range steps { + if err := step(); err != nil { + return nil, err + } + } + return s.result, nil +} + +// mapSession holds the state shared across the steps of a single Map call. +type mapSession struct { + c *RSP + t *Target + m *Macho + dylib []byte + image []byte + base uint64 + chunk int + scratch uint64 // next free offset in the rw scratch region + stub uint64 // base of the r-x stub page + helpers map[string]uint64 + resolver Resolver + result *Result +} + +// alloc hands out 8-byte-aligned chunks of the rw scratch region. +func (s *mapSession) alloc(size uint64) uint64 { + s.scratch = (s.scratch + 7) &^ 7 + p := s.scratch + s.scratch += size + return p +} + +// reserveImage mmaps the whole image r-x (executable). iOS fixes max_protection +// at mmap time, so EXEC must be requested now; the writable segments are then +// remapped rw over this reservation. +func (s *mapSession) reserveImage(imageSize uint64) error { + base, err := s.t.Call(s.helpers["mmap"], 0, imageSize, protRead|protExec, mapAnon|mapPrivate, neg1, 0) + if err != nil { + return err + } + if base == neg1 { + return fmt.Errorf("reserve mmap failed (MAP_FAILED)") + } + s.base = base + s.result.Base = base + return nil +} + +// remapWritableSegments MAP_FIXED-remaps each writable, non-__TEXT segment rw. +func (s *mapSession) remapWritableSegments(mapped []Segment) error { + for _, seg := range mapped { + if seg.Name == "__TEXT" || seg.InitProt&vmProtWrite == 0 { + continue + } + at := s.base + seg.VMAddr + got, err := s.t.Call(s.helpers["mmap"], at, seg.VMSize, protRead|protWrite, mapAnon|mapPrivate|mapFixed, neg1, 0) + if err != nil { + return err + } + if got != at { + return fmt.Errorf("MAP_FIXED %s @ %#x -> %#x", seg.Name, at, got) + } + } + return nil +} + +// setupScratchAndStubs maps the rw scratch region and the r-x stub page, writes +// the loop stubs, and points the call-return breakpoint at a private address in +// the stub page (rather than the thread's live PC) so re-entrant callee code — +// NSLog/libdispatch in the initializer — can't trip it early. +func (s *mapSession) setupScratchAndStubs() error { + scratch, err := s.t.Call(s.helpers["mmap"], 0, scratchSize, protRead|protWrite, mapAnon|mapPrivate, neg1, 0) + if err != nil { + return err + } + stub, err := s.t.Call(s.helpers["mmap"], 0, stubPageSize, protRead|protExec, mapAnon|mapPrivate, neg1, 0) + if err != nil { + return err + } + if err := s.c.WriteMem(stub, StubBytes, s.chunk); err != nil { + return err + } + s.scratch, s.stub = scratch, stub + return s.t.SetLanding(stub + landingPadOffset) +} + +// bindImports resolves every imported symbol in one in-process rstub_dlsym call +// (names + a pointer table + an output table staged in scratch), then applies +// the chained fixups to the image buffer locally. +func (s *mapSession) bindImports() error { + if !s.m.HasChained { + return nil + } + imports, weak, segs, err := ParseChainedFixups(s.dylib, s.m.ChainedOff) + if err != nil { + return err + } + s.result.Imports = len(imports) + + var blob []byte + offs := make([]int, len(imports)) + for i, n := range imports { + offs[i] = len(blob) + blob = append(blob, stripOneUnderscore(n)...) + blob = append(blob, 0) + } + blobBase := s.alloc(uint64(len(blob))) + if err := s.c.WriteMem(blobBase, blob, s.chunk); err != nil { + return err + } + + namesTbl := s.alloc(uint64(len(imports)) * 8) + tbl := make([]byte, len(imports)*8) + for i := range imports { + binary.LittleEndian.PutUint64(tbl[i*8:], blobBase+uint64(offs[i])) + } + if err := s.c.WriteMem(namesTbl, tbl, s.chunk); err != nil { + return err + } + outTbl := s.alloc(uint64(len(imports)) * 8) + if _, err := s.t.Call(s.stub+StubDlsymOff, s.helpers["dlsym"], uint64(len(imports)), namesTbl, outTbl, rtldDefault); err != nil { + return err + } + + raw, err := s.c.Memory(outTbl, uint64(len(imports))*8) + if err != nil { + return err + } + importAddrs := make([]uint64, len(imports)) + for i := range imports { + importAddrs[i] = binary.LittleEndian.Uint64(raw[i*8:]) + // A weak import may legitimately resolve to 0 (the code null-checks it); + // a missing non-weak import would make the initializer call a null pointer + // and crash the app, so fail now, before the image is written. + if importAddrs[i] == 0 && !weak[i] { + s.result.Unresolved = append(s.result.Unresolved, imports[i]) + } + } + if len(s.result.Unresolved) > 0 { + return fmt.Errorf("unresolved imports (missing symbols in target): %v", s.result.Unresolved) + } + rebases, binds, err := ApplyFixups(s.image, segs, s.base, importAddrs) + if err != nil { + return err + } + s.result.Rebases, s.result.Binds = rebases, binds + return nil +} + +// writeImage writes the fixed-up image into the reserved mapping. +func (s *mapSession) writeImage() error { + return s.c.WriteMem(s.base, s.image, s.chunk) +} + +// registerClasses uniques the image's selrefs and registers its classes with +// objc_readClassPair — one in-process stub call each. +func (s *mapSession) registerClasses() error { + classlist, ok := s.m.Sections["__objc_classlist"] + if !ok || classlist.Size == 0 { + return nil + } + if selrefs, ok := s.m.Sections["__objc_selrefs"]; ok && selrefs.Size > 0 { + n := selrefs.Size / 8 + if _, err := s.t.Call(s.stub+StubSelrefOff, s.helpers["sel_registerName"], n, s.base+selrefs.Addr); err != nil { + return err + } + s.result.Selrefs = int(n) + } + var imageinfo uint64 + if ii, ok := s.m.Sections["__objc_imageinfo"]; ok { + imageinfo = s.base + ii.Addr + } + n := classlist.Size / 8 + if _, err := s.t.Call(s.stub+StubClassOff, s.helpers["objc_readClassPair"], n, s.base+classlist.Addr, imageinfo); err != nil { + return err + } + s.result.Classes = int(n) + return nil +} + +// applyImageCategories attaches the image's __objc_catlist categories (which +// objc_readClassPair does not), before the initializer that may swizzle them. +func (s *mapSession) applyImageCategories() error { + if s.m.SectionCount("__objc_catlist") == 0 { + return nil + } + helpers, err := s.resolver.Resolve(categoryHelpers) + if err != nil { + return fmt.Errorf("resolve category helpers: %w", err) + } + applied, err := applyCategories(s.c, s.t, s.base, s.m, helpers) + if err != nil { + return err + } + s.result.Categories = applied + return nil +} + +// runInitializers runs each offset-based initializer (the agent's C constructor), +// which is what starts the agent. +func (s *mapSession) runInitializers() error { + for _, is := range s.m.InitSections { + for o := uint64(0); o < is.Size; o += 4 { + initOff := uint64(binary.LittleEndian.Uint32(s.image[is.Addr+o:])) + if _, err := s.t.Call(s.base + initOff); err != nil { + return err + } + } + } + return nil +} + +// stripOneUnderscore removes exactly one leading linker underscore (not all). +func stripOneUnderscore(s string) string { + if len(s) > 0 && s[0] == '_' { + return s[1:] + } + return s +} diff --git a/devices/ios/machomap/resolver_hostfiles.go b/devices/ios/machomap/resolver_hostfiles.go new file mode 100644 index 00000000..3f9d2d96 --- /dev/null +++ b/devices/ios/machomap/resolver_hostfiles.go @@ -0,0 +1,133 @@ +package machomap + +import ( + "encoding/binary" + "fmt" + "os" + "strings" +) + +// HostFilesResolver resolves symbols from on-disk Xcode device-symbol Mach-O +// files plus the single shared-cache slide read from the device. This is the +// fast Mac-only resolver used to prove the Go mapper matches the Python +// prototype; AWS Device Farm uses the host-independent export-trie resolver. +type HostFilesResolver struct { + c *RSP + files []string + slide uint64 + haveSlide bool +} + +// NewHostFilesResolver builds a resolver over the standard device-symbol layout +// under symbolRoot (…/Symbols/usr/lib). +func NewHostFilesResolver(c *RSP, symbolRoot string) *HostFilesResolver { + return &HostFilesResolver{ + c: c, + files: []string{ + symbolRoot + "/system/libsystem_kernel.dylib", + symbolRoot + "/system/libdyld.dylib", + symbolRoot + "/libobjc.A.dylib", + }, + } +} + +// Resolve returns runtime addresses for names, searching each symbol file's +// LC_SYMTAB for "_name" and adding the shared-cache slide. +func (r *HostFilesResolver) Resolve(names []string) (map[string]uint64, error) { + if !r.haveSlide { + slide, err := readSharedCacheSlide(r.c) + if err != nil { + return nil, err + } + r.slide, r.haveSlide = slide, true + } + want := map[string]bool{} + for _, n := range names { + want["_"+n] = true + } + found := map[string]uint64{} + for _, f := range r.files { + data, err := os.ReadFile(f) + if err != nil { + continue // not every name lives in every file + } + scanSymtab(data, want, func(name string, value uint64) { + found[strings.TrimPrefix(name, "_")] = value + r.slide + }) + } + var missing []string + for _, n := range names { + if _, ok := found[n]; !ok { + missing = append(missing, n) + } + } + if len(missing) > 0 { + return nil, fmt.Errorf("symbols not found in host symbol files: %v", missing) + } + return found, nil +} + +// readSharedCacheSlide reads dyld_all_image_infos and returns the shared-cache +// slide (offset 152), requiring dyld info version >= 15 and libSystemInitialized. +func readSharedCacheSlide(c *RSP) (uint64, error) { + resp, err := c.Request("qShlibInfoAddr") + if err != nil { + return 0, err + } + var addr uint64 + if _, err := fmt.Sscanf(resp, "%x", &addr); err != nil { + return 0, fmt.Errorf("qShlibInfoAddr %q: %w", resp, err) + } + info, err := c.Memory(addr, 184) + if err != nil { + return 0, err + } + if binary.LittleEndian.Uint32(info) < 15 { + return 0, fmt.Errorf("unsupported dyld info layout") + } + if info[25] == 0 { + return 0, fmt.Errorf("libSystem not initialized yet") + } + return binary.LittleEndian.Uint64(info[152:]), nil +} + +// scanSymtab walks a thin Mach-O's LC_SYMTAB and calls hit(name, value) for each +// wanted symbol with a nonzero value. +func scanSymtab(data []byte, want map[string]bool, hit func(name string, value uint64)) { + if len(data) < 32 || binary.LittleEndian.Uint32(data) != machoMagic64 { + return + } + ncmds := binary.LittleEndian.Uint32(data[16:]) + off := 32 + for i := uint32(0); i < ncmds; i++ { + if off+8 > len(data) { + return + } + cmd := binary.LittleEndian.Uint32(data[off:]) + size := binary.LittleEndian.Uint32(data[off+4:]) + if size == 0 { + return + } + if cmd == lcSymtab { + symoff := binary.LittleEndian.Uint32(data[off+8:]) + nsyms := binary.LittleEndian.Uint32(data[off+12:]) + stroff := binary.LittleEndian.Uint32(data[off+16:]) + for n := uint32(0); n < nsyms; n++ { + base := int(symoff) + int(n)*16 + if base+16 > len(data) { + break + } + strx := binary.LittleEndian.Uint32(data[base:]) + value := binary.LittleEndian.Uint64(data[base+8:]) + if value == 0 { + continue + } + name := cstrAt(data, int(stroff)+int(strx), len(data)) + if want[name] { + hit(name, value) + } + } + } + off += int(size) + } +} diff --git a/devices/ios/machomap/rsp.go b/devices/ios/machomap/rsp.go new file mode 100644 index 00000000..861fba96 --- /dev/null +++ b/devices/ios/machomap/rsp.go @@ -0,0 +1,219 @@ +package machomap + +import ( + "bufio" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "strings" +) + +// RSP is a GDB Remote Serial Protocol client for the on-device debugserver. It +// fully decodes responses — run-length compression ('*') and byte escaping +// ('}') — which is required when we consume register and memory reads ourselves +// (rather than forwarding raw bytes to an external lldb). Mirrors the proven +// raw_attach.py prototype. +type RSP struct { + w io.Writer + r *bufio.Reader + noAck bool +} + +// NewRSP negotiates no-ack mode on an already-connected debugserver stream. +func NewRSP(rw io.ReadWriter) (*RSP, error) { + c := &RSP{w: rw, r: bufio.NewReaderSize(rw, 1<<16)} + if _, err := c.w.Write([]byte("+")); err != nil { + return nil, err + } + resp, err := c.Request("QStartNoAckMode") + if err != nil { + return nil, err + } + if resp != "OK" { + return nil, fmt.Errorf("no-ack handshake failed: %q", resp) + } + c.noAck = true + return c, nil +} + +// Request sends a packet and returns the decoded response payload, skipping +// asynchronous console-output ('O...') packets. +func (c *RSP) Request(pkt string) (string, error) { + if err := c.send(pkt); err != nil { + return "", err + } + for { + resp, err := c.read() + if err != nil { + return "", err + } + if len(resp) > 1 && resp[0] == 'O' && resp != "OK" { + continue // console output; ignore + } + return resp, nil + } +} + +// OK sends a packet and requires the "OK" response. +func (c *RSP) OK(pkt string) error { + resp, err := c.Request(pkt) + if err != nil { + return err + } + if resp != "OK" { + return fmt.Errorf("%.40s: %q", pkt, resp) + } + return nil +} + +func (c *RSP) send(pkt string) error { + var b strings.Builder + b.WriteByte('$') + sum := byte(0) + for i := 0; i < len(pkt); i++ { + ch := pkt[i] + if ch == '#' || ch == '$' || ch == '*' || ch == '}' { + b.WriteByte('}') + b.WriteByte(ch ^ 0x20) + sum += '}' + (ch ^ 0x20) + } else { + b.WriteByte(ch) + sum += ch + } + } + fmt.Fprintf(&b, "#%02x", sum) + _, err := c.w.Write([]byte(b.String())) + return err +} + +// read reads one '$...#xx' packet and returns its decoded payload. +func (c *RSP) read() (string, error) { + // skip to '$' + for { + ch, err := c.r.ReadByte() + if err != nil { + return "", err + } + if ch == '$' { + break + } + } + var wire []byte + for { + ch, err := c.r.ReadByte() + if err != nil { + return "", err + } + if ch == '#' { + break + } + wire = append(wire, ch) + } + // consume the 2 checksum hex chars (not validated in no-ack mode, like raw_attach) + if _, err := c.r.Discard(2); err != nil { + return "", err + } + if !c.noAck { + if _, err := c.w.Write([]byte("+")); err != nil { + return "", err + } + } + return decodeRSP(wire), nil +} + +// decodeRSP expands '}' escaping and '*' run-length compression. +func decodeRSP(wire []byte) string { + out := make([]byte, 0, len(wire)) + for i := 0; i < len(wire); i++ { + ch := wire[i] + switch ch { + case '}': + i++ + out = append(out, wire[i]^0x20) + case '*': + i++ + repeat := int(wire[i]) - 29 + last := out[len(out)-1] + for r := 0; r < repeat; r++ { + out = append(out, last) + } + default: + out = append(out, ch) + } + } + return string(out) +} + +// Memory reads size bytes at addr. +func (c *RSP) Memory(addr, size uint64) ([]byte, error) { + resp, err := c.Request(fmt.Sprintf("m%x,%x", addr, size)) + if err != nil { + return nil, err + } + if strings.HasPrefix(resp, "E") { + return nil, fmt.Errorf("memory read @ %#x failed: %q", addr, resp) + } + data, err := hex.DecodeString(resp) + if err != nil { + return nil, fmt.Errorf("memory read @ %#x not hex: %w", addr, err) + } + if uint64(len(data)) != size { + return nil, fmt.Errorf("short memory read @ %#x: got %d want %d", addr, len(data), size) + } + return data, nil +} + +// WriteMem writes blob at addr in chunks (default 1024). The debugger can write +// pages the process itself cannot (e.g. r-x), the same W^X bypass it uses for +// breakpoints. +func (c *RSP) WriteMem(addr uint64, blob []byte, chunk int) error { + if chunk <= 0 { + chunk = 1024 + } + for i := 0; i < len(blob); i += chunk { + end := i + chunk + if end > len(blob) { + end = len(blob) + } + part := blob[i:end] + if err := c.OK(fmt.Sprintf("M%x,%x:%s", addr+uint64(i), len(part), hex.EncodeToString(part))); err != nil { + return err + } + } + return nil +} + +// readPtr reads a little-endian 64-bit pointer at addr. +func (c *RSP) readPtr(addr uint64) (uint64, error) { + b, err := c.Memory(addr, 8) + if err != nil { + return 0, err + } + return binary.LittleEndian.Uint64(b), nil +} + +// ReadReg reads a register by DWARF-ish number on a thread. +func (c *RSP) ReadReg(num int, thread uint64) (uint64, error) { + resp, err := c.Request(fmt.Sprintf("p%x;thread:%x;", num, thread)) + if err != nil { + return 0, err + } + if strings.HasPrefix(resp, "E") || resp == "" { + return 0, fmt.Errorf("read reg %d: %q", num, resp) + } + raw, err := hex.DecodeString(resp) + if err != nil { + return 0, fmt.Errorf("read reg %d not hex: %w", num, err) + } + var buf [8]byte + copy(buf[:], raw) // little-endian, may be shorter + return binary.LittleEndian.Uint64(buf[:]), nil +} + +// WriteReg sets a register by number on a thread. +func (c *RSP) WriteReg(num int, thread, value uint64) error { + var buf [8]byte + binary.LittleEndian.PutUint64(buf[:], value) + return c.OK(fmt.Sprintf("P%x=%s;thread:%x;", num, hex.EncodeToString(buf[:]), thread)) +} diff --git a/devices/ios/machomap/runtime.go b/devices/ios/machomap/runtime.go new file mode 100644 index 00000000..ee014153 --- /dev/null +++ b/devices/ios/machomap/runtime.go @@ -0,0 +1,43 @@ +package machomap + +// Helpers for driving the target after a map: calling an exported function, and +// checking whether a class is already registered (used to keep injection +// idempotent — mapping the same image twice fails on duplicate class pairs). + +// CallFunction borrows a thread and calls fn(args...) once, returning x0. Suitable +// for simple exported getters/setters; it uses the default return breakpoint, so +// callers should not use it for re-entrant functions (Map sets up a trampoline +// for those). +func CallFunction(c *RSP, fn uint64, args ...uint64) (uint64, error) { + t, err := NewTarget(c) + if err != nil { + return 0, err + } + defer func() { _ = t.Restore() }() + return t.Call(fn, args...) +} + +// ClassIsRegistered reports whether objc_getClass(name) is non-nil in the target. +func ClassIsRegistered(c *RSP, res Resolver, name string) (bool, error) { + h, err := res.Resolve([]string{"mmap", "objc_getClass"}) + if err != nil { + return false, err + } + t, err := NewTarget(c) + if err != nil { + return false, err + } + defer func() { _ = t.Restore() }() + scratch, err := t.Call(h["mmap"], 0, 0x1000, protRead|protWrite, mapAnon|mapPrivate, neg1, 0) + if err != nil { + return false, err + } + if err := c.WriteMem(scratch, append([]byte(name), 0), 0); err != nil { + return false, err + } + cls, err := t.Call(h["objc_getClass"], scratch) + if err != nil { + return false, err + } + return cls != 0, nil +} diff --git a/devices/ios/machomap/stubs.go b/devices/ios/machomap/stubs.go new file mode 100644 index 00000000..4991897e --- /dev/null +++ b/devices/ios/machomap/stubs.go @@ -0,0 +1,42 @@ +package machomap + +import "encoding/hex" + +// In-process loop stubs (source: stubs_arm64.asm in this package — named .asm so +// the Go toolchain ignores it; identical to the prototype's stubs.s). Each +// replaces a per-element vCont round-trip with a +// single call that loops inside the target, collapsing ~286 remote calls to ~8 +// and taking the map step from ~8s to ~0.4s: +// +// rstub_dlsym (x0=dlsym, x1=count, x2=names[], x3=out[], x4=handle): out[i]=dlsym(handle,names[i]) +// rstub_selref(x0=sel_registerName, x1=count, x2=slots): slots[i]=sel_registerName(slots[i]) +// rstub_class (x0=readClassPair, x1=count, x2=classlist, x3=imginfo): readClassPair(classlist[i],imginfo) +// +// Regenerate: clang -arch arm64 -c stubs_arm64.asm -o stubs.o && extract __TEXT,__text. +const stubHex = "" + + "f353bca9f55b01a9f76302a9fd7b03a9f30300aaf40301aaf50302aaf60303aa" + + "f70304aa180080d21f0314ebea000054e00317aaa17a78f860023fd6c07a38f8" + + "18070091f9ffff17fd7b43a9f76342a9f55b41a9f353c4a8c0035fd6f353bca9" + + "f55b01a9f76302a9fd7b03a9f30300aaf40301aaf50302aa180080d21f0314eb" + + "ca000054a07a78f860023fd6a07a38f818070091faffff17fd7b43a9f76342a9" + + "f55b41a9f353c4a8c0035fd6f353bca9f55b01a9f76302a9fd7b03a9f30300aa" + + "f40301aaf50302aaf60303aa180080d21f0314ebca000054a07a78f8e10316aa" + + "60023fd618070091faffff17fd7b43a9f76342a9f55b41a9f353c4a8c0035fd6" + +// StubBytes is the assembled machine code for the three loop stubs. +var StubBytes = mustHex(stubHex) + +// Offsets of each stub within StubBytes. +const ( + StubDlsymOff = 0x00 + StubSelrefOff = 0x5c + StubClassOff = 0xac +) + +func mustHex(s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + panic(err) + } + return b +} diff --git a/devices/ios/machomap/stubs_arm64.asm b/devices/ios/machomap/stubs_arm64.asm new file mode 100644 index 00000000..9054e87f --- /dev/null +++ b/devices/ios/machomap/stubs_arm64.asm @@ -0,0 +1,91 @@ +// In-process loop stubs for the signing-free mapper. +// Each is called ONCE via the debugger instead of one vCont round-trip per element. +// Uniform frame: save x19-x24, x29/x30 (64 bytes). + +.text +.align 2 + +// rstub_dlsym(x0=fn_dlsym, x1=count, x2=names[], x3=out[], x4=handle) +// for i in 0..count: out[i] = dlsym(handle, names[i]) +.globl _rstub_dlsym +_rstub_dlsym: + stp x19, x20, [sp, #-64]! + stp x21, x22, [sp, #16] + stp x23, x24, [sp, #32] + stp x29, x30, [sp, #48] + mov x19, x0 // fn + mov x20, x1 // count + mov x21, x2 // names + mov x22, x3 // out + mov x23, x4 // handle + mov x24, #0 // i +1: + cmp x24, x20 + b.ge 2f + mov x0, x23 + ldr x1, [x21, x24, lsl #3] + blr x19 + str x0, [x22, x24, lsl #3] + add x24, x24, #1 + b 1b +2: + ldp x29, x30, [sp, #48] + ldp x23, x24, [sp, #32] + ldp x21, x22, [sp, #16] + ldp x19, x20, [sp], #64 + ret + +// rstub_selref(x0=fn_sel, x1=count, x2=slots) +// for i: slots[i] = sel_registerName(slots[i]) // slot holds the name ptr, replaced by SEL +.globl _rstub_selref +_rstub_selref: + stp x19, x20, [sp, #-64]! + stp x21, x22, [sp, #16] + stp x23, x24, [sp, #32] + stp x29, x30, [sp, #48] + mov x19, x0 // fn + mov x20, x1 // count + mov x21, x2 // slots + mov x24, #0 // i +1: + cmp x24, x20 + b.ge 2f + ldr x0, [x21, x24, lsl #3] + blr x19 + str x0, [x21, x24, lsl #3] + add x24, x24, #1 + b 1b +2: + ldp x29, x30, [sp, #48] + ldp x23, x24, [sp, #32] + ldp x21, x22, [sp, #16] + ldp x19, x20, [sp], #64 + ret + +// rstub_class(x0=fn_readpair, x1=count, x2=classlist, x3=imageinfo) +// for i: objc_readClassPair(classlist[i], imageinfo) +.globl _rstub_class +_rstub_class: + stp x19, x20, [sp, #-64]! + stp x21, x22, [sp, #16] + stp x23, x24, [sp, #32] + stp x29, x30, [sp, #48] + mov x19, x0 // fn + mov x20, x1 // count + mov x21, x2 // classlist + mov x22, x3 // imageinfo + mov x24, #0 // i +1: + cmp x24, x20 + b.ge 2f + ldr x0, [x21, x24, lsl #3] + mov x1, x22 + blr x19 + add x24, x24, #1 + b 1b +2: + ldp x29, x30, [sp, #48] + ldp x23, x24, [sp, #32] + ldp x21, x22, [sp, #16] + ldp x19, x20, [sp], #64 + ret diff --git a/devices/ios/machomap/target.go b/devices/ios/machomap/target.go new file mode 100644 index 00000000..5893267e --- /dev/null +++ b/devices/ios/machomap/target.go @@ -0,0 +1,139 @@ +package machomap + +import ( + "fmt" + "strings" +) + +// ARM64 register numbers as debugserver exposes them. +const ( + regLR = 30 + regSP = 31 + regPC = 32 +) + +// Target borrows a stopped thread in the attached process so we can call +// functions in it: save its registers, back up a stack window, and plant a +// breakpoint at a known return address. Restore() puts everything back. +type Target struct { + c *RSP + thread uint64 + saved string + returnPC uint64 + landing uint64 // address of the breakpoint a called function returns to + sp uint64 + stackLo uint64 + stackBackup []byte +} + +// NewTarget borrows the first enumerated thread (the main thread on the apps we +// target) and prepares it for calls. +func NewTarget(c *RSP) (*Target, error) { + if err := c.OK("QThreadSuffixSupported"); err != nil { + return nil, err + } + threads, err := c.Request("qfThreadInfo") + if err != nil { + return nil, err + } + if !strings.HasPrefix(threads, "m") { + return nil, fmt.Errorf("cannot enumerate threads: %q", threads) + } + var thread uint64 + if _, err := fmt.Sscanf(strings.Split(threads[1:], ",")[0], "%x", &thread); err != nil { + return nil, fmt.Errorf("parse thread id %q: %w", threads, err) + } + t := &Target{c: c, thread: thread} + t.saved, err = c.Request(fmt.Sprintf("QSaveRegisterState;thread:%x;", thread)) + if err != nil { + return nil, err + } + if !isDigits(t.saved) { + return nil, fmt.Errorf("cannot save registers: %q", t.saved) + } + if t.returnPC, err = c.ReadReg(regPC, thread); err != nil { + return nil, err + } + if t.sp, err = c.ReadReg(regSP, thread); err != nil { + return nil, err + } + t.stackLo = (t.sp - 0x8000) &^ 15 + if t.stackBackup, err = c.Memory(t.stackLo, t.sp-t.stackLo); err != nil { + return nil, err + } + // Default: a called function returns to a breakpoint planted at the thread's + // current PC. That is unsafe for functions that re-enter the code the thread + // was stopped in (NSLog/libdispatch/runloop), which can trip the breakpoint + // mid-execution. SetLanding switches to a private trampoline that callee code + // never naturally executes. + t.landing = t.returnPC + if err := c.OK(fmt.Sprintf("Z0,%x,4", t.landing)); err != nil { + return nil, err + } + return t, nil +} + +// SetLanding moves the return-breakpoint to addr — a dedicated, never-naturally- +// executed address (e.g. a private r-x page). This avoids false early stops when +// a called function re-enters the code region the thread was stopped in. +func (t *Target) SetLanding(addr uint64) error { + if err := t.c.OK(fmt.Sprintf("z0,%x,4", t.landing)); err != nil { + return err + } + t.landing = addr + return t.c.OK(fmt.Sprintf("Z0,%x,4", addr)) +} + +// Call invokes fn(args...) on the borrowed thread and returns x0. LR points at +// the planted breakpoint, so the function returns into a known stop. +func (t *Target) Call(fn uint64, args ...uint64) (uint64, error) { + for i, a := range args { + if err := t.c.WriteReg(i, t.thread, a); err != nil { + return 0, err + } + } + if err := t.c.WriteReg(regLR, t.thread, t.landing); err != nil { + return 0, err + } + if err := t.c.WriteReg(regSP, t.thread, t.sp-0x100); err != nil { + return 0, err + } + if err := t.c.WriteReg(regPC, t.thread, fn); err != nil { + return 0, err + } + resp, err := t.c.Request(fmt.Sprintf("vCont;c:%x", t.thread)) + if err != nil { + return 0, err + } + pc, err := t.c.ReadReg(regPC, t.thread) + if err != nil { + return 0, err + } + if !strings.HasPrefix(resp, "T05") || pc != t.landing { + return 0, fmt.Errorf("unexpected stop calling %#x: resp=%.60q pc=%#x", fn, resp, pc) + } + return t.c.ReadReg(0, t.thread) +} + +// Restore undoes the borrow: stack window, registers, breakpoint. +func (t *Target) Restore() error { + if err := t.c.WriteMem(t.stackLo, t.stackBackup, 1024); err != nil { + return err + } + if err := t.c.OK(fmt.Sprintf("QRestoreRegisterState:%s;thread:%x;", t.saved, t.thread)); err != nil { + return err + } + return t.c.OK(fmt.Sprintf("z0,%x,4", t.landing)) +} + +func isDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} diff --git a/devices/ios_device_agent.go b/devices/ios_device_agent.go index edabee19..fd338a9b 100644 --- a/devices/ios_device_agent.go +++ b/devices/ios_device_agent.go @@ -5,15 +5,10 @@ import ( "encoding/json" "fmt" "net" - "os/exec" - "regexp" - "strconv" - "strings" "sync" "time" goios "github.com/danielpaulus/go-ios/ios" - "github.com/mobile-next/mobilecli/agents" iosutil "github.com/mobile-next/mobilecli/devices/ios" "github.com/mobile-next/mobilecli/utils" ) @@ -37,10 +32,6 @@ import ( // already-running agent without scanning or persisting state between runs. const iosDeviceAgentPort = 12008 -// deviceAgentPortRE extracts the bound port from LLDB's expression result line, -// e.g. "(int) $0 = 12008". -var deviceAgentPortRE = regexp.MustCompile(`\$\d+\s*=\s*(\d+)`) - // deviceAgentPortCache maps device UDID → local TCP port of its injected agent. // This is intentionally separate from the simulator's (udid, bundleID) cache in // ios_webview.go: a real device runs a single injected agent per device. @@ -164,17 +155,10 @@ func (d *IOSDevice) injectFreshAgent() (int, error) { return 0, fmt.Errorf("no foreground app (pid 0) — open an app first") } - utils.Verbose("injecting agent into %s (pid %d) via LLDB", activeApp.BundleID, activeApp.ProcessID) - lldbProxyPort, cancelProxy, err := startLLDBProxy(device, proxyPort, activeApp.ProcessID) - if err != nil { - return 0, fmt.Errorf("start lldb proxy: %w", err) - } - defer cancelProxy() - utils.Verbose("LLDB proxy listening on localhost:%d", lldbProxyPort) - - devicePort, err := injectServerViaLLDB(lldbProxyPort) + utils.Verbose("mapping agent dylib into %s (pid %d)", activeApp.BundleID, activeApp.ProcessID) + devicePort, err := injectAgentNative(device, proxyPort, activeApp.ProcessID) if err != nil { - return 0, fmt.Errorf("inject server via lldb: %w", err) + return 0, fmt.Errorf("inject agent: %w", err) } utils.Verbose("agent started on device port %d", devicePort) @@ -217,45 +201,6 @@ func iosDeviceDebugProxyPort(device goios.DeviceEntry) (int, error) { return p, nil } -// injectServerViaLLDB connects LLDB to the proxy (which has already attached to -// the target process), evaluates the agent expression to start a persistent HTTP -// server inside the app, and returns the device-side TCP port. -func injectServerViaLLDB(localProxyPort int) (int, error) { - const lldbTimeout = 120 * time.Second - ctx, cancel := context.WithTimeout(context.Background(), lldbTimeout) - defer cancel() - - utils.Verbose("running LLDB (timeout %s)", lldbTimeout) - cmd := exec.CommandContext(ctx, "lldb", - "-o", "settings set target.process.memory-cache-line-size 16384", - "-o", "platform select remote-ios", - "-o", fmt.Sprintf("process connect connect://localhost:%d", localProxyPort), - // The leading newline after "--" is required: it puts LLDB into multi-line - // expression mode so the whole agent source is treated as one expression - // (without it, LLDB runs only the first line and parses the rest as commands). - "-o", "expr -l objc -- \n"+agents.IOSRealDeviceWebViewAgent, - "-o", "detach", - "-o", "quit", - ) - out, err := cmd.CombinedOutput() - utils.Verbose("LLDB finished (err=%v), output:\n%s", err, out) - if err != nil { - return 0, fmt.Errorf("lldb: %w\noutput:\n%s", err, out) - } - - for _, line := range strings.Split(string(out), "\n") { - m := deviceAgentPortRE.FindStringSubmatch(line) - if m == nil { - continue - } - port, err := strconv.Atoi(m[1]) - if err == nil && port == iosDeviceAgentPort { - return port, nil - } - } - return 0, fmt.Errorf("could not parse port from lldb output:\n%s", out) -} - // freeLocalPort asks the kernel for an unused local TCP port (bind :0, read it // back, release it). Only the device-side agent port is fixed // (iosDeviceAgentPort, for cross-run reuse discovery); the local end of the diff --git a/devices/ios_device_agent_native.go b/devices/ios_device_agent_native.go new file mode 100644 index 00000000..62258471 --- /dev/null +++ b/devices/ios_device_agent_native.go @@ -0,0 +1,106 @@ +package devices + +import ( + "fmt" + "io" + "strings" + "time" + + goios "github.com/danielpaulus/go-ios/ios" + "github.com/mobile-next/mobilecli/agents" + "github.com/mobile-next/mobilecli/devices/ios/machomap" + "github.com/mobile-next/mobilecli/utils" +) + +// injectTimeout bounds the whole debugserver map. The map normally takes a few +// seconds; this is only a ceiling so a wedged device (a debugserver reply that +// never arrives) cannot hang the CLI/daemon indefinitely. +const injectTimeout = 60 * time.Second + +// injectAgentNative maps the compiled agent dylib into the foreground app over +// the developer debugserver and runs its initializer, with no code signing and +// no re-signing (the CS_DEBUGGED exemption lets the debugger execute the pages +// it wrote). It replaces the old LLDB `expr -l objc` source-injection path and +// works where dlopen does not (AWS Device Farm). Returns the device-side TCP port +// the agent's server bound. +func injectAgentNative(device goios.DeviceEntry, proxyPort, pid int) (int, error) { + conn, err := goios.ConnectTUNDevice(device.Address, proxyPort, device) + if err != nil { + return 0, fmt.Errorf("connect debug proxy: %w", err) + } + defer func() { _ = conn.Close() }() + + // Bound the blocking RSP exchanges: closing the connection when the deadline + // expires unblocks any pending read in mapAgentOverConn, which then returns an + // error instead of hanging. + timedOut := false + timer := time.AfterFunc(injectTimeout, func() { timedOut = true; _ = conn.Close() }) + defer timer.Stop() + + port, err := mapAgentOverConn(conn, pid) + if timedOut { + return 0, fmt.Errorf("agent injection timed out after %s (device unresponsive)", injectTimeout) + } + return port, err +} + +// mapAgentOverConn runs the attach + map + boot sequence on an already-opened +// debugserver stream. Split out from injectAgentNative so the logic can be tested +// over any transport (e.g. the go-ios userspace-tunnel forwarder). +func mapAgentOverConn(conn io.ReadWriter, pid int) (int, error) { + rsp, err := machomap.NewRSP(conn) + if err != nil { + return 0, fmt.Errorf("debugserver handshake: %w", err) + } + if r, err := rsp.Request("QSetDetachOnError:1"); err != nil || r != "OK" { + return 0, fmt.Errorf("QSetDetachOnError: resp=%q err=%v", r, err) + } + stop, err := rsp.Request(fmt.Sprintf("vAttach;%x", pid)) + if err != nil || !strings.HasPrefix(stop, "T") { + return 0, fmt.Errorf("vAttach pid %d: resp=%q err=%v", pid, stop, err) + } + defer func() { _, _ = rsp.Request("D") }() + + resolver := machomap.NewExportTrieResolver(rsp) + + // Idempotency: never map the same image twice into one process — duplicate + // ObjC class registration fails and leaves the image half-wired. If the agent + // is already present (e.g. after a daemon restart while the app kept running), + // the in-process port cache is gone and the agent's ephemeral port cannot be + // recovered, so ask the caller to relaunch the app. + if already, err := machomap.ClassIsRegistered(rsp, resolver, "MobileServer"); err != nil { + return 0, fmt.Errorf("probe existing agent: %w", err) + } else if already { + return 0, fmt.Errorf("agent already injected in pid %d; relaunch the app to re-inject", pid) + } + + res, err := machomap.Map(rsp, agents.IOSAgentDevDylib, resolver, machomap.Options{RunInit: true}) + if err != nil { + return 0, fmt.Errorf("map agent dylib: %w", err) + } + utils.Verbose("native map: base=%#x size=%#x rebases=%d binds=%d classes=%d categories=%d", + res.Base, res.ImageSize, res.Rebases, res.Binds, res.Classes, res.Categories) + if len(res.Unresolved) > 0 { + return 0, fmt.Errorf("agent has %d unresolved imports: %v", len(res.Unresolved), res.Unresolved) + } + + // Read the server's bound port from the agent's exported getter. + m, err := machomap.ParseMacho(agents.IOSAgentDevDylib) + if err != nil { + return 0, err + } + off, ok := m.Symbols["_mobilecli_get_port"] + if !ok { + return 0, fmt.Errorf("agent dylib missing mobilecli_get_port") + } + port, err := machomap.CallFunction(rsp, res.Base+off) + if err != nil { + return 0, fmt.Errorf("read agent port: %w", err) + } + p := int(uint32(port)) + if p == 0 { + return 0, fmt.Errorf("agent initializer ran but server did not bind") + } + utils.Verbose("native map: agent server bound device port %d", p) + return p, nil +} diff --git a/devices/ios_device_agent_native_test.go b/devices/ios_device_agent_native_test.go new file mode 100644 index 00000000..1cb22174 --- /dev/null +++ b/devices/ios_device_agent_native_test.go @@ -0,0 +1,65 @@ +package devices + +import ( + "encoding/binary" + "fmt" + "net" + "os" + "strconv" + "testing" + "time" +) + +// theNativeAgentMapperBootsTheAgentOverTheTunnel maps the embedded agent dylib +// into a live foreground app and confirms its server bound a port — the full +// injectAgentNative path minus the go-ios transport, exercised over the userspace +// tunnel forwarder so it needs no WDA/devicekit setup. +// +// Gated on NATIVE_INJECT_TEST; skipped otherwise so CI without a device stays +// green. Env: NATIVE_INJECT_HOST (tunnel IPv6), NATIVE_INJECT_TUNNEL_PORT (local +// forwarder), NATIVE_INJECT_PORT (device debugproxy), NATIVE_INJECT_PID (a FRESH +// foreground-app pid — the agent must not already be injected). +func TestNativeAgentMapperBootsOverTunnel(t *testing.T) { + if os.Getenv("NATIVE_INJECT_TEST") == "" { + t.Skip("set NATIVE_INJECT_TEST=1 with a connected device to run") + } + host := os.Getenv("NATIVE_INJECT_HOST") + tunnelPort := mustAtoi(t, "NATIVE_INJECT_TUNNEL_PORT") + proxyPort := mustAtoi(t, "NATIVE_INJECT_PORT") + pid := mustAtoi(t, "NATIVE_INJECT_PID") + + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", tunnelPort), 8*time.Second) + if err != nil { + t.Fatalf("dial forwarder: %v", err) + } + defer func() { _ = conn.Close() }() + // go-ios userspace-tunnel forwarder header: 16-byte IPv6 + LE uint32 port + ip := net.ParseIP(host).To16() + if ip == nil { + t.Fatalf("bad NATIVE_INJECT_HOST %q", host) + } + hdr := make([]byte, 20) + copy(hdr[:16], ip) + binary.LittleEndian.PutUint32(hdr[16:], uint32(proxyPort)) + if _, err := conn.Write(hdr); err != nil { + t.Fatalf("write forwarder header: %v", err) + } + + port, err := mapAgentOverConn(conn, pid) + if err != nil { + t.Fatalf("mapAgentOverConn: %v", err) + } + if port == 0 { + t.Fatal("agent mapped but server bound no port") + } + t.Logf("agent booted; server bound device port %d", port) +} + +func mustAtoi(t *testing.T, env string) int { + t.Helper() + v, err := strconv.Atoi(os.Getenv(env)) + if err != nil { + t.Fatalf("%s: %v", env, err) + } + return v +} diff --git a/devices/ios_device_lldbproxy.go b/devices/ios_device_lldbproxy.go deleted file mode 100644 index a74e52d2..00000000 --- a/devices/ios_device_lldbproxy.go +++ /dev/null @@ -1,181 +0,0 @@ -package devices - -import ( - "context" - "fmt" - "io" - "net" - "strings" - - goios "github.com/danielpaulus/go-ios/ios" - "github.com/mobile-next/mobilecli/devices/ios/debugserver" - "github.com/mobile-next/mobilecli/utils" -) - -// This file is the low-level GDB Remote Serial Protocol bridge that lets the -// system `lldb` binary talk to a process on the device. The device's debugserver -// is reachable only through the CoreDevice debug proxy (go-ios); lldb expects to -// connect to a local debugserver. startLLDBProxy/lldbProxyConn bridge the two. -// -// It is feature-agnostic: any injected-agent feature (webview today; view-tree, -// network capture, etc. later) relies on it to evaluate code in the target. - -// startLLDBProxy pre-attaches to pid on the device via the debug proxy, then -// starts a local TCP listener for LLDB. Pre-attaching before listening ensures -// the proxy can respond to LLDB's handshake immediately upon connection. -// Returns the local port and a stop function. -func startLLDBProxy(device goios.DeviceEntry, proxyPort, pid int) (int, func(), error) { - utils.Verbose("lldb-proxy: connecting to device debug proxy port %d", proxyPort) - devConn, err := goios.ConnectTUNDevice(device.Address, proxyPort, device) - if err != nil { - return 0, nil, fmt.Errorf("lldb-proxy: connect to device: %w", err) - } - - devGDB := debugserver.NewGDBServer(devConn) - utils.Verbose("lldb-proxy: pre-attaching to pid %d", pid) - stopReply, err := devGDB.Request(fmt.Sprintf("vAttach;%x", pid)) - if err != nil || !strings.HasPrefix(stopReply, "T") { - _ = devConn.Close() - return 0, nil, fmt.Errorf("lldb-proxy: vAttach pid %d: err=%v resp=%q", pid, err, stopReply) - } - utils.Verbose("lldb-proxy: pre-attached, stop=%q", stopReply) - - ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", "127.0.0.1:0") - if err != nil { - _ = devConn.Close() - return 0, nil, fmt.Errorf("listen for lldb proxy: %w", err) - } - addr, ok := ln.Addr().(*net.TCPAddr) - if !ok { - _ = ln.Close() - _ = devConn.Close() - return 0, nil, fmt.Errorf("lldb proxy listener address is %T, not TCP", ln.Addr()) - } - localPort := addr.Port - go func() { - defer func() { _ = ln.Close() }() - defer func() { _ = devConn.Close() }() - conn, err := ln.Accept() - if err != nil { - return - } - lldbProxyConn(conn, devGDB, pid) - }() - return localPort, func() { _ = ln.Close() }, nil -} - -// lldbProxyConn is a GDB RSP bridge between LLDB and an already-attached -// device debugserver. Handles negotiation packets locally, forwards all others -// packet-by-packet with ack-mode translation (LLDB: no-ack; device: ack). -func lldbProxyConn(c net.Conn, devGDB *debugserver.GDBServer, pid int) { - defer func() { _ = c.Close() }() - - // debugserver sends '+' immediately upon accepting a connection; - // LLDB waits for this before sending the first packet. - c.Write([]byte("+")) //nolint:errcheck - - noAck := false - - sendToLLDB := func(pkt string) { - ck := gdbChecksum(pkt) - var s string - if !noAck { - s = "+" - } - s += fmt.Sprintf("$%s#%02x", pkt, ck) - c.Write([]byte(s)) //nolint:errcheck - } - - for { - pkt, err := readGDBPacket(c) - if err != nil { - return - } - utils.Verbose("lldb-proxy ← LLDB: %.300s", pkt) - - // switchToNoAck is set by QStartNoAckMode and applied AFTER sendToLLDB - // so the OK response goes out in ack mode (with '+') as LLDB expects. - switchToNoAck := false - var reply string - switch { - case pkt == "QStartNoAckMode": - reply = "OK" - switchToNoAck = true - - case strings.HasPrefix(pkt, "qSupported"): - reply = "PacketSize=65536;vContSupported+" - - case pkt == "QThreadSuffixSupported", - pkt == "QListThreadsInStopReply", - pkt == "qVAttachOrWaitSupported", - pkt == "QEnableErrorStrings": - reply = "OK" - - case strings.HasPrefix(pkt, "vCont?"): - reply = "vCont;c;C;s;S" - - case pkt == "k": - // LLDB wants to kill — detach instead so the app keeps running - devGDB.Request(fmt.Sprintf("D;%x", pid)) //nolint:errcheck - return - - case strings.HasPrefix(pkt, "D"): - devReply, err := devGDB.Request(pkt) - utils.Verbose("lldb-proxy → LLDB (detach): %d bytes, err=%v", len(devReply), err) - sendToLLDB(devReply) - return - - default: - // forward to device (devGDB uses ack mode: sends "+$pkt#XX") - devReply, err := devGDB.Request(pkt) - if err != nil { - utils.Verbose("lldb-proxy: device error for %q: %v", pkt[:min(len(pkt), 40)], err) - return - } - reply = devReply - } - - utils.Verbose("lldb-proxy → LLDB: %d bytes", len(reply)) - sendToLLDB(reply) - if switchToNoAck { - noAck = true - } - } -} - -func gdbChecksum(pkt string) byte { - var sum byte - for i := 0; i < len(pkt); i++ { - sum += pkt[i] - } - return sum -} - -// readGDBPacket reads one "$pkt#XX" packet and returns pkt; bytes before the -// '$' (acks) and the checksum are dropped. -func readGDBPacket(r io.Reader) (string, error) { - buf := make([]byte, 1) - for { - if _, err := io.ReadFull(r, buf); err != nil { - return "", err - } - if buf[0] == '$' { - break - } - } - var pkt strings.Builder - for { - if _, err := io.ReadFull(r, buf); err != nil { - return "", err - } - if buf[0] == '#' { - break - } - pkt.WriteByte(buf[0]) - } - cksumBuf := make([]byte, 2) - if _, err := io.ReadFull(r, cksumBuf); err != nil { - return "", err - } - return pkt.String(), nil -} diff --git a/devices/ios_webview.go b/devices/ios_webview.go index a44612a1..29c6953b 100644 --- a/devices/ios_webview.go +++ b/devices/ios_webview.go @@ -127,6 +127,7 @@ func writeIOSAgentDylib() (string, error) { } var portFromLLDB = regexp.MustCompile(`\$\d+\s*=\s*(\d+)`) +var simulatorListeningPort = regexp.MustCompile(`TCP 127\.0\.0\.1:(\d+) \(LISTEN\)`) // psLineRegex matches a ps aux line and captures PID (group 1) and the full // command string including spaces (group 2). @@ -135,6 +136,37 @@ var psLineRegex = regexp.MustCompile(`^\S+\s+(\d+)(?:\s+\S+){8}\s+(.+)$`) const lldbTimeout = 30 * time.Second +// findRunningSimulatorAgent finds the agent socket owned by this app process. +// The daemon's in-memory port cache is lost when it restarts, while the dylib +// stays loaded until the app exits. Loading another copy into that process +// would run its constructor and URLSession swizzles a second time. +func findRunningSimulatorAgent(pid int) (int, bool) { + out, err := exec.Command("lsof", "-nP", "-a", "-p", strconv.Itoa(pid), "-iTCP").Output() + if err != nil { + return 0, false + } + for _, port := range parseSimulatorListeningPorts(string(out)) { + if _, err := agentRequestWithTimeout(port, "device.webview.list", nil, time.Second); err == nil { + return port, true + } + } + return 0, false +} + +func parseSimulatorListeningPorts(output string) []int { + var ports []int + for _, line := range strings.Split(output, "\n") { + match := simulatorListeningPort.FindStringSubmatch(line) + if match == nil { + continue + } + if port, err := strconv.Atoi(match[1]); err == nil { + ports = append(ports, port) + } + } + return ports +} + // injectIOSAgent attaches lldb to the given PID, loads the dylib, reads the // bound port via mobilecli_get_port(), then detaches. Returns the port. func injectIOSAgent(pid int, dylibPath string) (int, error) { @@ -219,6 +251,10 @@ func (s *SimulatorDevice) ensureIOSAgentReady() (int, error) { if err != nil { return 0, fmt.Errorf("cannot attach to %s: app does not have get-task-allow entitlement — use a debug build", foreground.PackageName) } + if port, ok := findRunningSimulatorAgent(pid); ok { + setCachedAgentPort(s.UDID, foreground.PackageName, port) + return port, nil + } utils.Verbose("attaching to %s (pid %d, bundle %s)", appBundlePath, pid, foreground.PackageName) diff --git a/devices/ios_webview_agent_test.go b/devices/ios_webview_agent_test.go new file mode 100644 index 00000000..6c6da8c9 --- /dev/null +++ b/devices/ios_webview_agent_test.go @@ -0,0 +1,16 @@ +package devices + +import ( + "reflect" + "testing" +) + +func TestParseSimulatorListeningPorts(t *testing.T) { + output := "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n" + + "playgroun 92664 gilm 11u IPv4 0x1 0t0 TCP 192.168.0.101:55975->13.225.245.14:443 (ESTABLISHED)\n" + + "playgroun 92664 gilm 13u IPv4 0x2 0t0 TCP 127.0.0.1:56022 (LISTEN)\n" + + "playgroun 92664 gilm 14u IPv4 0x3 0t0 TCP 0.0.0.0:3000 (LISTEN)\n" + if got := parseSimulatorListeningPorts(output); !reflect.DeepEqual(got, []int{56022}) { + t.Fatalf("ports = %v, want [56022]", got) + } +} diff --git a/devices/network.go b/devices/network.go new file mode 100644 index 00000000..c7c4f0e1 --- /dev/null +++ b/devices/network.go @@ -0,0 +1,110 @@ +package devices + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +// networkPollTimeout bounds one network.poll call. It must exceed the agent's +// own poll hold (~30s) so a quiet window returns empty rather than erroring. +const networkPollTimeout = 35 * time.Second + +// agentNetwork relays the NetworkInterceptable surface to the in-app agent over +// its forwarded RPC port, the same channel agentWebViews uses. It holds no +// interception logic itself — the agent does the work; this just forwards JSON. +type agentNetwork struct { + port int +} + +func (a agentNetwork) NetworkEnable(routes []NetworkRoute) error { + if routes == nil { + routes = []NetworkRoute{} + } + _, err := agentRequest(a.port, "network.enable", map[string]any{"routes": routes}) + return err +} + +func (a agentNetwork) NetworkPoll(ctx context.Context) ([]json.RawMessage, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + result, err := agentRequestContext(ctx, a.port, "network.poll", map[string]any{}, networkPollTimeout) + if err != nil { + return nil, err + } + var payload struct { + Events []json.RawMessage `json:"events"` + } + if err := json.Unmarshal(result, &payload); err != nil { + return nil, fmt.Errorf("parse network events: %w", err) + } + return payload.Events, nil +} + +func (a agentNetwork) NetworkContinue(params map[string]any) error { + _, err := agentRequest(a.port, "network.continue", params) + return err +} + +func (a agentNetwork) NetworkFulfill(params map[string]any) error { + _, err := agentRequest(a.port, "network.fulfill", params) + return err +} + +func (a agentNetwork) NetworkAbort(params map[string]any) error { + _, err := agentRequest(a.port, "network.abort", params) + return err +} + +func (a agentNetwork) NetworkDisable() error { + _, err := agentRequest(a.port, "network.disable", map[string]any{}) + return err +} + +// NetworkInterceptor attaches to the debuggable foreground app on Android and +// returns a relay to its in-app agent. It mirrors webViews()' foreground +// resolution but only takes the agent path (a browser cannot be intercepted this +// way). +func (d *AndroidDevice) NetworkInterceptor() (NetworkInterceptable, error) { + var foreground *ForegroundAppInfo + var err error + deadline := time.Now().Add(3 * time.Second) + for { + foreground, err = d.GetForegroundApp() + if err == nil { + break + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("could not determine foreground app: %w", err) + } + time.Sleep(150 * time.Millisecond) + } + port, err := d.ensureAgentReady(foreground.PackageName) + if err != nil { + return nil, err + } + return agentNetwork{port: port}, nil +} + +// NetworkInterceptor attaches to the debuggable foreground app in the simulator +// and returns a relay to its in-app agent. +func (s *SimulatorDevice) NetworkInterceptor() (NetworkInterceptable, error) { + port, err := s.ensureIOSAgentReady() + if err != nil { + return nil, err + } + return agentNetwork{port: port}, nil +} + +// NetworkInterceptor maps the agent dylib into the foreground app on a real iOS +// device (signing-free, over the debugserver) and returns a relay to its in-app +// agent. The agent's dispatcher routes network.* RPC to MobileNetwork. +func (d *IOSDevice) NetworkInterceptor() (NetworkInterceptable, error) { + port, err := d.ensureIOSDeviceAgentReady() + if err != nil { + return nil, err + } + return agentNetwork{port: port}, nil +} diff --git a/docs/network-interception.md b/docs/network-interception.md new file mode 100644 index 00000000..7376490d --- /dev/null +++ b/docs/network-interception.md @@ -0,0 +1,104 @@ +# Network capture and interception + +mobilecli can observe and intercept the HTTP traffic of a **debuggable app you +own** — an Android app with `android:debuggable="true"`, or an iOS app built with +the `get-task-allow` entitlement (any Debug build). This is the backend for a +Playwright-style `route()` in mobilewright: the host gets a callback when the app +makes a request and decides whether to let it through, change it, fake a +response, or fail it. + +It reuses the injected-agent channel that already drives webviews: a JVMTI `.so` +on Android, a dylib on iOS. On iOS the dylib is mapped into the app by the +debugger-assisted Mach-O mapper (`devices/ios/machomap`) over the developer +`debugserver` reached through the go-ios userspace tunnel — no LLDB and no +re-signing, so it runs on real `get-task-allow` devices, not just the simulator. +The network methods are added to that same in-app agent; nothing new is injected. +Only debuggable apps can be attached to at all, so this never applies to App Store +or release builds. + +## Model + +The contract mirrors the Chrome DevTools "Fetch" domain, because Playwright's +`route()` is a thin layer over it. Porting mobilewright becomes a near-direct +mapping. + +- The host **enables** interception with a list of routes (URL glob + optional + method). The agent pauses only requests that match a route, so unmatched + traffic never makes a round trip to the host. +- The agent emits a **paused** event per matched request, carrying a request id + unique within the session. +- The host replies with exactly one decision for that id: **continue** + (optionally with a changed URL, method, headers or body), **fulfill** (a + synthetic status, headers and body), or **abort** (fail with an error code). +- Capture-only events (**request**, **response**, **finished**, **failed**) + never pause; they back `page.on('request')` and friends, and later HAR + recording. (Phase 2.) + +### Fail open + +This is the rule that keeps the app usable. A paused request continues +**unchanged** whenever a decision cannot be obtained: + +- the host stops polling (it disconnected or crashed), +- no decision arrives within the agent's decision timeout (~60s), +- interception is disabled. + +Without this, a crashed test script would hang every request the app makes. + +## Transport + +The host talks to the in-app agent over the existing forwarded RPC socket +(`agentRequest` in `devices/android_rpc.go`). The methods: + +| Method | Params | Returns | +|---------------------|--------------------------------------------------------------|-------------------------| +| `network.enable` | `{routes: [{url, method?}]}` | `{status:"ok"}` | +| `network.poll` | `{}` — long-polls, returns when an event is ready or ~30s | `{events: [ ... ]}` | +| `network.continue` | `{requestId, url?, method?, headers?, body?}` | `{status:"ok"}` | +| `network.fulfill` | `{requestId, status, headers?, body?}` | `{status:"ok"}` | +| `network.abort` | `{requestId, errorCode?}` | `{status:"ok"}` | +| `network.disable` | `{}` | `{status:"ok"}` | + +`network.poll` long-polls so events arrive promptly without the host busy-waiting. +The host runs one poll loop per `listen` and relays each event object to the CLI +as a `stream.data` notification. Decisions are separate, idempotent calls keyed +by `requestId`. + +Bodies are base64 in both directions. A paused/response body larger than the +agent's cap (256 KiB) is truncated in the event (with `bodyTruncated: true`) but +never truncated in the app itself. + +### Event shapes + +```jsonc +// matched request, paused, awaiting a decision +{ "type": "paused", "requestId": "r12", "method": "GET", + "url": "https://api.example.com/user", "headers": {"accept": "*/*"}, + "body": null, "stack": "okhttp" } +``` + +## CLI + +``` +# stream paused requests as NDJSON on stdout; decide from another terminal or a coprocess +mobilecli network listen --device --pause '**/api/**' [--pause 'POST **/login'] + +# decisions, keyed by the requestId from a paused event +mobilecli network continue --device --id r12 [--url ... --method ... --header K=V --body @file] +mobilecli network fulfill --device --id r12 --status 200 [--header K=V --body @file] +mobilecli network abort --device --id r12 [--error failed] +``` + +`--pause` is a glob matched against the full URL. `PREFIX URL` form (`'POST **/x'`) +restricts to a method. Repeat `--pause` for multiple routes. + +## Scope / status + +- **Phase 1 (this doc):** pause + continue/fulfill/abort on Android (OkHttp) and + iOS (URLSession), on the iOS simulator and real `get-task-allow` devices. +- **Phase 2:** capture-only events, response-stage interception, HAR recording, + `HttpURLConnection`, webviews via CDP Fetch. + +The in-app hooks live in `agents/android/java/` and `agents/ios/`. The host side +— daemon stream method, CLI, device plumbing — lives in `devices/network.go`, +`commands/network.go`, `cli/network.go`, and `server/daemon_streams.go`. diff --git a/server/daemon.go b/server/daemon.go index 5ffbae47..9b3f05f3 100644 --- a/server/daemon.go +++ b/server/daemon.go @@ -44,8 +44,9 @@ type cliStreamHandler func(ctx context.Context, params json.RawMessage, notify f // cliStreamRegistry holds CLI methods that emit stream.data notifications. func cliStreamRegistry() map[string]cliStreamHandler { return map[string]cliStreamHandler{ - "cli.device.logs": streamLogs, - "cli.screencapture": streamScreenCapture, - "cli.screenrecord": streamScreenRecord, + "cli.device.logs": streamLogs, + "cli.screencapture": streamScreenCapture, + "cli.screenrecord": streamScreenRecord, + "cli.network.listen": streamNetwork, } } diff --git a/server/daemon_streams.go b/server/daemon_streams.go index 9405e16c..c3c514fb 100644 --- a/server/daemon_streams.go +++ b/server/daemon_streams.go @@ -120,6 +120,14 @@ func streamScreenCapture(ctx context.Context, params json.RawMessage, notify fun return commands.NewSuccessResponse(commands.OK), nil } +func streamNetwork(ctx context.Context, params json.RawMessage, notify func(any) error) (any, error) { + var req commands.NetworkListenRequest + if err := json.Unmarshal(params, &req); err != nil { + return commands.NewErrorResponse(fmt.Errorf("invalid parameters: %w", err)), nil + } + return commands.NetworkListenCommand(ctx, req, notify), nil +} + func streamScreenRecord(ctx context.Context, params json.RawMessage, notify func(any) error) (any, error) { var req commands.ScreenRecordStreamRequest if err := json.Unmarshal(params, &req); err != nil { diff --git a/test/network.spec.ts b/test/network.spec.ts new file mode 100644 index 00000000..19017b4d --- /dev/null +++ b/test/network.spec.ts @@ -0,0 +1,367 @@ +import {test, expect} from './fixtures'; +import {execFileSync, spawn, type ChildProcessWithoutNullStreams} from 'child_process'; +import * as path from 'path'; +import {coverageEnv} from './coverage'; +import {eventually} from './poll'; +import {flattenElements} from './webview'; +import type {UIElement, UIDumpResponse} from './types'; +import {downloadPlayground, PLAYGROUND_PACKAGE} from './playground'; + +const mobilecliBinary = path.join(__dirname, '..', 'mobilecli'); + +// the playground Network screen (playground://network) sends HTTP through the +// stacks a real app uses: OkHttp and HttpURLConnection on android, URLSession and +// Alamofire on ios. each button shows the outcome in one on-screen label, so a test can read +// what the app actually received after interception. +const GET_BUTTON: Record = { + android: 'OkHttp GET', + ios: 'URLSession.shared GET', +}; + +// the label every result line starts with, so the on-screen outcome is findable +// regardless of status or body +const RESULT_PREFIXES = ['okhttp', 'urlconnection', 'shared', 'custom', 'session', 'alamofire']; + +type Device = {id: string; platform: string}; + +// one paused request as emitted by `network listen` (see docs/network-interception.md) +type PausedEvent = { + type: string; + requestId: string; + method: string; + url: string; + body?: string | null; +}; + +function firstDeviceOfType(deviceType: string): Device | null { + try { + const output = execFileSync(mobilecliBinary, ['devices', '--type', deviceType], { + encoding: 'utf8', + env: coverageEnv(), + }); + return JSON.parse(output)?.data?.devices?.[0] ?? null; + } catch { + return null; + } +} + +function simulatorPlaygroundPID(deviceId: string): number | null { + const processes = execFileSync('ps', ['-axo', 'pid=,command='], {encoding: 'utf8'}); + const line = processes.split('\n').find(line => + line.includes(`/CoreSimulator/Devices/${deviceId}/`) && line.includes('/playground.app/playground')); + return line ? Number(line.trim().match(/^\d+/)?.[0]) : null; +} + +function runMobilecli(args: string[]): string { + return execFileSync(mobilecliBinary, args, {encoding: 'utf8', env: coverageEnv()}); +} + +// a command that returns an error envelope exits non-zero, so execFileSync +// throws even though it printed valid json on stdout; recover that stdout. +function runMobilecliJson(args: string[]): any { + try { + return JSON.parse(runMobilecli(args)); + } catch (error: unknown) { + const stdout = (error as {stdout?: string}).stdout ?? ''; + if (stdout.trim() === '') throw error; + return JSON.parse(stdout); + } +} + +// the text/label/name/value of an element, so one matcher works on both the +// android dump (text) and the ios dump (label/name) +function elementText(el: UIElement): string { + const anyEl = el as unknown as Record; + return anyEl.text || anyEl.label || anyEl.name || anyEl.value || ''; +} + +function dumpElements(deviceId: string): UIElement[] { + const dump: UIDumpResponse = runMobilecliJson(['dump', 'ui', '--device', deviceId]); + return flattenElements(dump.data.elements); +} + +function findByText(deviceId: string, text: string): UIElement | undefined { + return dumpElements(deviceId).find(el => elementText(el) === text); +} + +function tapButton(deviceId: string, text: string): void { + const el = findByText(deviceId, text); + if (!el) { + throw new Error(`button "${text}" not found`); + } + const {x, y, width, height} = el.rect; + runMobilecli(['io', 'tap', `${Math.round(x + width / 2)},${Math.round(y + height / 2)}`, '--device', deviceId]); +} + +// the on-screen outcome line, once the request has resolved (e.g. +// "okhttp GET 200 {...}" or "shared GET error: ...") +function readNetworkResult(deviceId: string): string { + const el = dumpElements(deviceId).find(e => + RESULT_PREFIXES.some(p => elementText(e).startsWith(p))); + return el ? elementText(el) : ''; +} + +function openNetworkScreen(deviceId: string): void { + runMobilecli(['url', 'playground://network', '--device', deviceId]); +} + +// `network listen` streams one json object per line on stdout. this starts it, +// parses paused events as they arrive, and hands back the collected list plus a +// stop() that ends the stream (releasing anything still paused). +function startListening(deviceId: string, pauses: string[]): {events: PausedEvent[]; stop: () => Promise} { + const args = ['network', 'listen', '--device', deviceId]; + for (const p of pauses) { + args.push('--pause', p); + } + const child: ChildProcessWithoutNullStreams = spawn(mobilecliBinary, args, {env: coverageEnv()}); + const events: PausedEvent[] = []; + let buffer = ''; + child.stdout.on('data', chunk => { + buffer += chunk.toString(); + let newline: number; + while ((newline = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (!line) continue; + try { + const parsed = JSON.parse(line); + if (parsed.type === 'paused') events.push(parsed); + } catch { + // a non-event line (e.g. an error envelope) is left for the caller's assertions + } + } + }); + const stop = () => new Promise(resolve => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + child.on('close', () => resolve()); + child.kill('SIGINT'); + }); + return {events, stop}; +} + +async function tapUntilPaused(deviceId: string, button: string, events: PausedEvent[]): Promise { + const previousCount = events.length; + const deadline = Date.now() + 20000; + while (Date.now() < deadline) { + tapButton(deviceId, button); + await new Promise(resolve => setTimeout(resolve, 800)); + if (events.length > previousCount) return events[previousCount]; + } + throw new Error(`${button} did not pause within 20 seconds`); +} + +for (const platform of ['android', 'ios'] as const) { + test.describe(`network (${platform})`, () => { + let device: Device | null; + + test.beforeAll(async ({deviceType}) => { + device = firstDeviceOfType(deviceType); + if (device && device.platform !== platform) device = null; + if (device) { + await downloadPlayground(platform); + } + }); + + // ── host contract: these hold today, with or without the in-app agent ── + + test('continue/fulfill/abort require a requestId', () => { + test.skip(!device, `no ${platform} device`); + for (const action of ['continue', 'fulfill', 'abort']) { + const envelope = runMobilecliJson(['network', action, '--device', device!.id, '--id', '']); + expect(envelope.status).toBe('error'); + expect(envelope.error).toContain('requestId is required'); + } + }); + + test('listen attaches to a debuggable app and reaches its in-app agent', async () => { + test.skip(!device, `no ${platform} device`); + openNetworkScreen(device!.id); + await eventually(() => !!findByText(device!.id, GET_BUTTON[platform]), + 'network screen never appeared').toBe(true); + + const listener = startListening(device!.id, [`**/todos/**`]); + tapButton(device!.id, GET_BUTTON[platform]); + + // once the agent implements network.*, this becomes a paused event (see the + // skipped interception suite). until then it must at least reach the agent, + // proving injection + port-forward + rpc relay + streaming all work. + await eventually(() => listener.events.length > 0 || agentWasReached(device!.id), + 'listen never reached the in-app agent').toBe(true); + await listener.stop(); + }); + + // ── interception acceptance for both in-app agents ── + test.describe('interception', () => { + test('fulfill replaces the response the app receives', async () => { + test.skip(!device, `no ${platform} device`); + openNetworkScreen(device!.id); + await eventually(() => !!findByText(device!.id, GET_BUTTON[platform]), 'no screen').toBe(true); + + const listener = startListening(device!.id, ['**/todos/**']); + tapButton(device!.id, GET_BUTTON[platform]); + await eventually(() => listener.events.length, 'no paused request').toBeGreaterThan(0); + + runMobilecli(['network', 'fulfill', '--device', device!.id, + '--id', listener.events[0].requestId, '--status', '200', '--body', '{"mocked":true}']); + + await eventually(() => readNetworkResult(device!.id), 'mocked body never shown') + .toContain('mocked'); + await listener.stop(); + }); + + test('abort makes the request fail in the app', async () => { + test.skip(!device, `no ${platform} device`); + openNetworkScreen(device!.id); + const listener = startListening(device!.id, ['**/todos/**']); + tapButton(device!.id, GET_BUTTON[platform]); + await eventually(() => listener.events.length, 'no paused request').toBeGreaterThan(0); + + runMobilecli(['network', 'abort', '--device', device!.id, + '--id', listener.events[0].requestId, '--error', 'failed']); + + await eventually(() => readNetworkResult(device!.id), 'request did not fail') + .toContain('error'); + await listener.stop(); + }); + + test('continue lets the real response through unchanged', async () => { + test.skip(!device, `no ${platform} device`); + openNetworkScreen(device!.id); + const listener = startListening(device!.id, ['**/todos/**']); + tapButton(device!.id, GET_BUTTON[platform]); + await eventually(() => listener.events.length, 'no paused request').toBeGreaterThan(0); + + runMobilecli(['network', 'continue', '--device', device!.id, '--id', listener.events[0].requestId]); + + await eventually(() => readNetworkResult(device!.id), 'real response never arrived') + .toContain('200'); + await listener.stop(); + }); + + test('iOS URLSession and Alamofire HTTP and HTTPS requests can be fulfilled', async () => { + test.skip(platform !== 'ios' || !device, 'requires the iOS simulator playground'); + openNetworkScreen(device!.id); + await eventually(() => !!findByText(device!.id, 'Alamofire HTTP GET'), 'no network screen').toBe(true); + const listener = startListening(device!.id, ['**/todos/**']); + try { + const buttons = [ + 'URLSession.shared GET', + 'URLSession HTTP GET', + 'Custom session GET', + 'Alamofire HTTPS GET', + 'Alamofire HTTP GET', + ]; + for (const [index, button] of buttons.entries()) { + const previousCount = listener.events.length; + tapButton(device!.id, button); + await eventually(() => listener.events.length, `${button} was not paused`) + .toBeGreaterThan(previousCount); + const event = listener.events[previousCount]; + const marker = `mock-${index}`; + const response = runMobilecliJson(['network', 'fulfill', '--device', device!.id, + '--id', event.requestId, '--status', '200', '--body', marker]); + expect(response.status).toBe('ok'); + await eventually(() => readNetworkResult(device!.id), `${button} did not show ${marker}`) + .toContain(marker); + } + } finally { + await listener.stop(); + } + }); + + test('iOS unmatched requests proceed without an event', async () => { + test.skip(platform !== 'ios' || !device, 'requires the iOS simulator playground'); + openNetworkScreen(device!.id); + const listener = startListening(device!.id, ['**/never-match/**']); + try { + tapButton(device!.id, 'Alamofire HTTPS GET'); + await eventually(() => readNetworkResult(device!.id), 'unmatched request did not complete') + .toContain('alamofire HTTPS GET 200'); + expect(listener.events).toHaveLength(0); + } finally { + await listener.stop(); + } + }); + + test('iOS listener exit releases a paused request unchanged', async () => { + test.skip(platform !== 'ios' || !device, 'requires the iOS simulator playground'); + openNetworkScreen(device!.id); + const listener = startListening(device!.id, ['**/todos/**']); + tapButton(device!.id, 'Alamofire HTTPS GET'); + await eventually(() => listener.events.length, 'request was not paused').toBeGreaterThan(0); + await listener.stop(); + await eventually(() => readNetworkResult(device!.id), 'request stayed paused after disconnect') + .toContain('alamofire HTTPS GET 200'); + }); + + test('iOS POST body is captured and can be replaced on continue', async () => { + test.skip(platform !== 'ios' || !device, 'requires the iOS simulator playground'); + openNetworkScreen(device!.id); + const listener = startListening(device!.id, ['POST **/posts']); + try { + tapButton(device!.id, 'URLSession.shared POST'); + await eventually(() => listener.events.length, 'POST was not paused').toBeGreaterThan(0); + const event = listener.events[0]; + expect(event.method).toBe('POST'); + expect(Buffer.from(event.body ?? '', 'base64').toString()).toContain('playground'); + runMobilecli(['network', 'continue', '--device', device!.id, + '--id', event.requestId, '--body', '{"title":"overridden","body":"ok","userId":2}']); + await eventually(() => readNetworkResult(device!.id), 'POST override did not arrive') + .toContain('overridden'); + } finally { + await listener.stop(); + } + }); + + test('iOS agent is reused after the daemon restarts', async () => { + test.skip(platform !== 'ios' || !device, 'requires the iOS simulator playground'); + openNetworkScreen(device!.id); + await eventually(() => !!findByText(device!.id, 'URLSession.shared GET'), 'no network screen').toBe(true); + const appPID = simulatorPlaygroundPID(device!.id); + expect(appPID).toBeTruthy(); + + const first = startListening(device!.id, ['**/todos/**']); + try { + const event = await tapUntilPaused(device!.id, 'URLSession.shared GET', first.events); + runMobilecli(['network', 'fulfill', '--device', device!.id, + '--id', event.requestId, '--status', '200', '--body', 'before-restart']); + await eventually(() => readNetworkResult(device!.id), 'first response did not arrive') + .toContain('before-restart'); + } finally { + await first.stop(); + } + + expect(runMobilecliJson(['daemon', 'stop']).status).toBe('ok'); + expect(simulatorPlaygroundPID(device!.id)).toBe(appPID); + + const second = startListening(device!.id, ['**/todos/**']); + try { + const event = await tapUntilPaused(device!.id, 'URLSession.shared GET', second.events); + runMobilecli(['network', 'fulfill', '--device', device!.id, + '--id', event.requestId, '--status', '200', '--body', 'after-restart']); + await eventually(() => readNetworkResult(device!.id), 'second response did not arrive') + .toContain('after-restart'); + expect(simulatorPlaygroundPID(device!.id)).toBe(appPID); + } finally { + await second.stop(); + } + }); + }); + }); +} + +// agentWasReached returns true when the foreground app is the debuggable +// playground, i.e. `network listen` had an agent to talk to. the "method not +// found: network.enable" error that listen prints today is itself proof it +// reached the agent; this is the positive-signal companion to that. +function agentWasReached(deviceId: string): boolean { + try { + const fg = runMobilecliJson(['apps', 'foreground', '--device', deviceId]); + return (fg?.data?.packageName ?? fg?.data?.bundleIdentifier) === PLAYGROUND_PACKAGE; + } catch { + return false; + } +} diff --git a/test/playwright.config.ts b/test/playwright.config.ts index 98f439e3..32a7613d 100644 --- a/test/playwright.config.ts +++ b/test/playwright.config.ts @@ -15,5 +15,8 @@ export default defineConfig<{}, DeviceTypeOptions>({ {name: 'simulator', testMatch: /simulator\.spec\.ts/}, {name: 'emulator', testMatch: /android\.spec\.ts/, use: {deviceType: 'emulator'}}, {name: 'android', testMatch: /android\.spec\.ts/, use: {deviceType: 'real'}}, + {name: 'network-simulator', testMatch: /network\.spec\.ts/, use: {deviceType: 'simulator'}}, + {name: 'network-emulator', testMatch: /network\.spec\.ts/, use: {deviceType: 'emulator'}}, + {name: 'network-android', testMatch: /network\.spec\.ts/, use: {deviceType: 'real'}}, ], });