Files
reverse-engineering-anything/src/frida/FridaInstrumentationManager.test.ts
T
morluto b4d501e714 fix(frida): preserve partial facts when redacted keys collide (#10071)
fix(frida): retain partial facts on redacted key collisions
2026-11-11 18:18:15 +08:00

2025 lines
69 KiB
TypeScript

import { describe, expect, it, onTestFinished, vi } from "vitest";
import { createDeferred } from "../../tests/fixtures/binarySession.js";
import type {
Device,
DeviceManager,
FridaMessage,
Process,
Script,
Session,
Signal,
} from "frida";
import { jsonObjectSchema } from "../domain/jsonValue.js";
import { FridaScriptLoadCancelledError } from "../application/frida/FridaInstrumentationPort.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisInputError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import { FridaInstrumentationManager } from "./FridaInstrumentationManager.js";
import { fridaCaptureLimitations } from "./fridaCaptureLimits.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { FridaInstrumentationService } from "../application/frida/FridaInstrumentationService.js";
const signal = <Handler extends (...args: never[]) => void>() => {
let connected: Handler | undefined;
const value: Signal<Handler> & {
emit: (...args: Parameters<Handler>) => void;
} = {
connect: (handler) => {
connected = handler;
},
disconnect: (handler) => {
if (connected === handler) connected = undefined;
},
emit: (...args) => connected?.(...args),
};
return value;
};
class FakeScript implements Script {
readonly message =
signal<(message: FridaMessage, data: Buffer | null) => void>();
isDestroyed = false;
loadFailure: Error | undefined;
unloadFailureCount = 0;
unloadCalls = 0;
constructor(
readonly payload: unknown = { event: "loaded" },
readonly beforeLoad: () => Promise<void> = async () => {},
readonly description = "script loaded",
readonly stack = "",
readonly messageValue?: FridaMessage,
) {}
async load(): Promise<void> {
await this.beforeLoad();
if (this.loadFailure !== undefined) throw this.loadFailure;
this.message.emit(
this.messageValue ?? {
type: "send",
payload: this.payload,
description: this.description,
stack: this.stack,
},
null,
);
}
async unload(): Promise<void> {
this.unloadCalls += 1;
if (this.unloadFailureCount > 0) {
this.unloadFailureCount -= 1;
throw new Error("script unload failed");
}
if (this.isDestroyed) throw new Error("Script is destroyed");
this.isDestroyed = true;
}
}
class FakeSession implements Session {
readonly detached = signal<() => void>();
readonly scripts: FakeScript[] = [];
nextPayload: unknown = { event: "loaded" };
nextDescription = "script loaded";
nextStack = "";
nextMessage: FridaMessage | undefined;
nextLoadFailure: Error | undefined;
nextUnloadFailureCount = 0;
detachFailureCount = 0;
scriptLoadGate: Promise<void> | undefined;
onScriptLoadStarted: (() => void) | undefined;
private detachedState = false;
readonly pid = 512;
isDetached(): boolean {
return this.detachedState;
}
async createScript(): Promise<Script> {
const script = new FakeScript(
this.nextPayload,
async () => {
this.onScriptLoadStarted?.();
await this.scriptLoadGate;
},
this.nextDescription,
this.nextStack,
this.nextMessage,
);
script.loadFailure = this.nextLoadFailure;
script.unloadFailureCount = this.nextUnloadFailureCount;
this.nextPayload = { event: "loaded" };
this.nextDescription = "script loaded";
this.nextStack = "";
this.nextMessage = undefined;
this.nextLoadFailure = undefined;
this.nextUnloadFailureCount = 0;
this.scripts.push(script);
return script;
}
async detach(): Promise<void> {
if (this.detachFailureCount > 0) {
this.detachFailureCount -= 1;
throw new Error("session detach failed");
}
for (const script of this.scripts) script.isDestroyed = true;
this.markDetached();
}
markDetached(): void {
this.detachedState = true;
this.detached.emit();
}
markDetachedWithoutSignal(): void {
this.detachedState = true;
}
}
class FakeDevice implements Device {
readonly id = "local";
readonly name = "Local System";
readonly type = "local";
readonly process = { pid: 512, name: "fixture" } satisfies Process;
readonly session = new FakeSession();
resumed: number[] = [];
attachedTargets: Array<number | string> = [];
resumeFailureCount = 0;
readonly spawned: string[] = [];
async enumerateProcesses(): Promise<Process[]> {
return [this.process];
}
async getProcessByPid(): Promise<Process> {
return this.process;
}
async spawn(program: string): Promise<number> {
this.spawned.push(program);
return this.process.pid;
}
async resume(pid: number): Promise<void> {
if (this.resumeFailureCount > 0) {
this.resumeFailureCount -= 1;
throw new Error("target resume failed");
}
this.resumed.push(pid);
}
async attach(target: number | string): Promise<Session> {
this.attachedTargets.push(target);
return this.session;
}
}
class FakeDeviceManager implements DeviceManager {
readonly device = new FakeDevice();
removedRemote: string[] = [];
removeRemoteFailureCount = 0;
removeRemoteFailureMessage = "remote device removal failed";
async enumerateDevices(): Promise<Device[]> {
return [this.device];
}
async getDeviceById(): Promise<Device> {
return this.device;
}
async addRemoteDevice(): Promise<Device> {
return this.device;
}
async removeRemoteDevice(address: string): Promise<void> {
if (this.removeRemoteFailureCount > 0) {
this.removeRemoteFailureCount -= 1;
throw new Error(this.removeRemoteFailureMessage);
}
this.removedRemote.push(address);
}
}
const startLocalAttachedSession = async (
bindings = new FakeDeviceManager(),
) => {
const manager = new FridaInstrumentationManager({ deviceManager: bindings });
const started = await manager.startSession({
mode: "attach",
deviceId: "local",
pid: 512,
});
return { bindings, manager, started };
};
// eslint-disable-next-line max-lines-per-function -- related manager lifecycle cases share the same focused fake provider.
describe("FridaInstrumentationManager", () => {
it("reports a fixed device-lookup deadline when Frida's lookup times out", async () => {
const bindings = new FakeDeviceManager();
bindings.getDeviceById = async () => {
throw new Error("Device not found");
};
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const listed = await manager.listProcesses({ deviceId: "local" });
expect(listed.ok).toBe(false);
if (listed.ok) return;
expect(listed.error).toBeInstanceOf(AnalysisTimeoutError);
expect(listed.error).toMatchObject({
operation: "list_frida_processes",
timeoutMs: 1_000,
});
if (!(listed.error instanceof AnalysisTimeoutError)) return;
expect(listed.error.userMessage).toContain("1000 ms");
expect(listed.error.userMessage).toContain("no runtime override");
expect(listed.error.remediationAction).toContain("no runtime override");
});
it("preserves a Frida device-manager failure instead of claiming the lookup deadline expired", async () => {
const bindings = new FakeDeviceManager();
bindings.getDeviceById = async () => {
throw new Error("Device manager is closed");
};
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const listed = await manager.listProcesses({ deviceId: "local" });
expect(listed.ok).toBe(false);
if (listed.ok) return;
expect(listed.error).toBeInstanceOf(AnalysisCapabilityUnavailableError);
expect(listed.error).not.toBeInstanceOf(AnalysisTimeoutError);
expect(listed.error).toMatchObject({
providerId: "frida",
operation: "list_frida_processes",
reason: "Device manager is closed",
});
expect(listed.error.message).toContain("Device manager is closed");
expect(listed.error.message).not.toContain("deadlineExpired");
});
it("names a capture cap only after that observation dropped data", () => {
expect(fridaCaptureLimitations({ messages_truncated: false })).toEqual([]);
expect(
fridaCaptureLimitations({ messages_truncated: true }).join("\n"),
).toContain("selected value is unknown");
const field = fridaCaptureLimitations({
messages: [{ type_truncated: true }],
}).join("\n");
expect(field).toContain("capture_limits.message_type_bytes");
expect(field).toContain("selected value is unknown");
expect(field).not.toContain("4096");
expect(field).not.toContain("16777216");
const text = fridaCaptureLimitations({
messages: [
{
description_truncated: true,
payload: { messages_truncated: true, type_truncated: true },
},
],
}).join("\n");
expect(text).toContain("capture_limits.message_text_bytes");
expect(text).toContain("selected value is unknown");
expect(text).not.toContain("32768");
expect(text).not.toContain("16777216");
expect(text).not.toContain("4096");
expect(
fridaCaptureLimitations({
messages: [{ payload: { messages_truncated: true } }],
}),
).toEqual([]);
});
it("attaches by PID when Frida omits the target from process lookup", async () => {
const bindings = new FakeDeviceManager();
bindings.device.getProcessByPid = async () => {
throw new Error("Process not found");
};
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "attach",
deviceId: "local",
pid: 512,
});
expect(started.ok).toBe(true);
if (!started.ok) return;
expect(started.value.pid).toBe(512);
expect(bindings.device.attachedTargets).toEqual([512]);
expect(started.value.target).toBe("pid 512");
expect(bindings.device.session.isDetached()).toBe(false);
await manager.closeAll();
expect(bindings.device.session.isDetached()).toBe(true);
});
it("keeps the caller PID when Frida resolves a different process", async () => {
const bindings = new FakeDeviceManager();
bindings.device.getProcessByPid = async () => ({
...bindings.device.process,
pid: 513,
});
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "attach",
deviceId: "local",
pid: 512,
});
expect(started.ok).toBe(true);
if (!started.ok) return;
expect(started.value.pid).toBe(512);
expect(started.value.target).toBe("fixture (pid 512)");
expect(bindings.device.attachedTargets).toEqual([512]);
await manager.closeSession(started.value.sessionId);
});
it("records a session already detached during process metadata lookup", async () => {
const bindings = new FakeDeviceManager();
let lookupStarted!: () => void;
const lookupEntered = new Promise<void>((resolve) => {
lookupStarted = resolve;
});
let finishLookup!: () => void;
const lookupGate = new Promise<void>((resolve) => {
finishLookup = resolve;
});
bindings.device.getProcessByPid = async () => {
lookupStarted();
await lookupGate;
return bindings.device.process;
};
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const starting = manager.startSession({
mode: "attach",
deviceId: "local",
pid: 512,
});
await lookupEntered;
bindings.device.session.markDetached();
finishLookup();
const started = await starting;
expect(started.ok).toBe(true);
if (!started.ok) return;
expect(started.value.pid).toBe(512);
expect(bindings.device.attachedTargets).toEqual([512]);
expect(started.value.state).toBe("detached");
expect(manager.status(started.value.sessionId)?.state).toBe("detached");
await manager.closeSession(started.value.sessionId);
});
it("still reports a real attach failure when process lookup is unavailable", async () => {
const bindings = new FakeDeviceManager();
bindings.device.getProcessByPid = async () => {
throw new Error("Process not found");
};
bindings.device.attach = async () => {
throw new Error("attach denied");
};
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "attach",
deviceId: "local",
pid: 512,
});
expect(started.ok).toBe(false);
if (started.ok) return;
expect(JSON.stringify(started.error)).toContain("attach denied");
await manager.closeAll();
});
it("retains a failed startup's spawn and remote lease until cleanup succeeds", async () => {
const bindings = new FakeDeviceManager();
bindings.device.attach = async () => {
throw new Error("attach failed after spawn");
};
bindings.device.resumeFailureCount = 2;
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "spawn",
remote: { address: "127.0.0.1:27042" },
program: "/tmp/fixture",
});
if (started.ok) throw new Error("Attach unexpectedly succeeded");
expect(started.error.cleanupIncomplete).toBe(true);
expect(bindings.device.resumed).toEqual([]);
expect(bindings.removedRemote).toEqual([]);
const closing = await manager.closeAll().catch((cause: unknown) => cause);
expect(closing).toBeInstanceOf(AnalysisError);
if (!(closing instanceof AnalysisError))
throw new Error("Expected typed cleanup failure");
expect(projectAnalysisError(closing)).toMatchObject({
code: "cleanup_incomplete",
});
expect(bindings.device.resumed).toEqual([]);
expect(bindings.removedRemote).toEqual([]);
// A lookup could now select a different device with the same PID. Cleanup
// must use the original provider handle, not select the target again.
const replacement = new FakeDevice();
bindings.getDeviceById = async () => replacement;
bindings.addRemoteDevice = async () => replacement;
await Promise.all([manager.closeAll(), manager.closeAll()]);
expect(started.error.cleanupIncomplete).toBe(false);
expect(closing.cleanupIncomplete).toBe(false);
expect(bindings.device.resumed).toEqual([512]);
expect(replacement.resumed).toEqual([]);
expect(bindings.removedRemote).toEqual(["127.0.0.1:27042"]);
await manager.closeAll();
expect(bindings.device.resumed).toEqual([512]);
expect(bindings.removedRemote).toEqual(["127.0.0.1:27042"]);
});
it("retains an acquired session when startup fails before registration", async () => {
const bindings = new FakeDeviceManager();
bindings.device.session.detached.connect = () => {
throw new Error("detached signal registration failed");
};
bindings.device.session.detachFailureCount = 1;
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
});
if (started.ok)
throw new Error("Signal registration unexpectedly succeeded");
expect(started.error.cleanupIncomplete).toBe(true);
expect(bindings.device.session.isDetached()).toBe(false);
expect(bindings.device.resumed).toEqual([]);
await manager.closeAll();
expect(bindings.device.session.isDetached()).toBe(true);
expect(bindings.device.resumed).toEqual([512]);
});
it("does not release another session's remote lease when retrying failed startup removal", async () => {
const bindings = new FakeDeviceManager();
bindings.device.attach = async () => {
throw new Error("attach failed");
};
bindings.removeRemoteFailureCount = 1;
const remote = { address: "127.0.0.1:27042" };
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const failed = await manager.startSession({
mode: "spawn",
remote,
program: "/tmp/fixture",
});
if (failed.ok) throw new Error("Attach unexpectedly succeeded");
expect(failed.error.cleanupIncomplete).toBe(true);
expect(bindings.device.resumed).toEqual([512]);
bindings.device.attach = async () => bindings.device.session;
const active = await manager.startSession({
mode: "attach",
remote,
pid: 512,
});
if (!active.ok) throw active.error;
const removedWhileSessionAttached: boolean[] = [];
const remove = bindings.removeRemoteDevice.bind(bindings);
bindings.removeRemoteDevice = async (address) => {
removedWhileSessionAttached.push(!bindings.device.session.isDetached());
await remove(address);
};
await manager.closeAll();
expect(removedWhileSessionAttached).toEqual([false]);
expect(bindings.removedRemote).toEqual([remote.address]);
expect(bindings.device.resumed).toEqual([512]);
});
it("loads instrumentation before resuming a spawned target", async () => {
const bindings = new FakeDeviceManager();
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
});
expect(started.ok).toBe(true);
if (!started.ok) return;
expect(started.value.state).toBe("paused");
expect(bindings.device.resumed).toEqual([]);
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send({ event: 'loaded' });",
});
expect(loaded.ok).toBe(true);
if (!loaded.ok) return;
expect(loaded.value.messages).toContainEqual(
expect.objectContaining({
type: "send",
payload: { event: "loaded" },
}),
);
expect(loaded.value.sourceSha256).toMatch(/^[a-f0-9]{64}$/u);
expect(bindings.device.resumed).toEqual([]);
const resumed = await manager.resumeSession(started.value.sessionId);
expect(resumed.ok).toBe(true);
expect(bindings.device.resumed).toEqual([512]);
expect(manager.status(started.value.sessionId)?.targetResumeObserved).toBe(
true,
);
expect(manager.status(started.value.sessionId)?.state).toBe("running");
await manager.closeAll();
expect(bindings.device.session.isDetached()).toBe(true);
});
it("removes a remote device after process discovery", async () => {
const bindings = new FakeDeviceManager();
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "attach",
remote: { address: "127.0.0.1:27042", token: "secret-token" },
pid: 512,
});
expect(started.ok).toBe(true);
if (!started.ok) return;
const listed = await manager.listProcesses({
remote: { address: "127.0.0.1:27042", token: "secret-token" },
});
expect(listed.ok).toBe(true);
if (listed.ok) {
expect(listed.value.processes[0]?.pid).toBe(512);
expect(listed.value.cleanupError).toBeNull();
}
expect(bindings.removedRemote).toEqual([]);
await manager.closeSession(started.value.sessionId);
expect(bindings.removedRemote).toEqual(["127.0.0.1:27042"]);
await manager.closeAll();
});
it("preserves remote discovery facts when cleanup fails and retries cleanup", async () => {
const bindings = new FakeDeviceManager();
bindings.removeRemoteFailureCount = 1;
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const listed = await manager.listProcesses({
remote: { address: "127.0.0.1:27042" },
});
expect(listed.ok).toBe(true);
if (listed.ok) {
expect(listed.value.processes).toEqual([
{ pid: 512, name: "fixture", identifier: null },
]);
expect(listed.value.cleanupError).toContain(
"remote device removal failed",
);
}
await manager.closeAll();
expect(bindings.removedRemote).toEqual(["127.0.0.1:27042"]);
});
it("does not expose remote credentials in shutdown cleanup failures", async () => {
const token = "synthetic-close-token";
const certificate = "synthetic-close-certificate";
const bindings = new FakeDeviceManager();
bindings.removeRemoteFailureCount = 2;
bindings.removeRemoteFailureMessage = `${token} ${certificate}`;
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "attach",
remote: { address: "127.0.0.1:27042", token, certificate },
pid: 512,
});
expect(started.ok).toBe(true);
if (!started.ok) return;
let failure = "";
try {
await manager.closeAll();
} catch (cause: unknown) {
failure =
cause instanceof AggregateError
? cause.errors.map((entry: unknown) => String(entry)).join("; ")
: String(cause);
}
expect(failure).not.toBe("");
expect(failure).not.toContain(token);
expect(failure).not.toContain(certificate);
});
it("unloads scripts, resumes a paused spawn, and detaches without killing it", async () => {
const bindings = new FakeDeviceManager();
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
});
expect(started.ok).toBe(true);
if (!started.ok) return;
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('ready');",
});
expect(loaded.ok).toBe(true);
const closed = await manager.closeSession(started.value.sessionId);
expect(closed.ok).toBe(true);
expect(bindings.device.session.scripts[0]?.isDestroyed).toBe(true);
expect(bindings.device.resumed).toEqual([512]);
expect(bindings.device.session.isDetached()).toBe(true);
});
it("retains a failed script handle until a later cleanup retry releases it", async () => {
const bindings = new FakeDeviceManager();
bindings.device.session.nextLoadFailure = new Error("script load failed");
bindings.device.session.nextUnloadFailureCount = 1;
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "attach",
deviceId: "local",
pid: 512,
});
expect(started.ok).toBe(true);
if (!started.ok) return;
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('ready');",
});
expect(loaded.ok).toBe(false);
if (!loaded.ok) expect(loaded.error.cleanupIncomplete).toBe(true);
expect(manager.status(started.value.sessionId)?.scripts).toHaveLength(1);
expect(bindings.device.session.scripts[0]?.unloadCalls).toBe(1);
const closed = await manager.closeSession(started.value.sessionId);
expect(closed.ok).toBe(true);
expect(bindings.device.session.scripts[0]?.unloadCalls).toBe(2);
expect(bindings.device.session.scripts[0]?.isDestroyed).toBe(true);
expect(manager.status(started.value.sessionId)).toBeUndefined();
if (!loaded.ok) expect(loaded.error.cleanupIncomplete).toBe(false);
});
it("closes a session when its target already destroyed the script", async () => {
const { bindings, manager, started } = await startLocalAttachedSession();
expect(started.ok).toBe(true);
if (!started.ok) return;
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('ready');",
});
expect(loaded.ok).toBe(true);
const script = bindings.device.session.scripts[0];
expect(script).toBeDefined();
if (script === undefined) return;
script.isDestroyed = true;
bindings.device.session.markDetached();
const closed = await manager.closeSession(started.value.sessionId);
expect(closed.ok).toBe(true);
expect(script.unloadCalls).toBe(0);
expect(manager.status(started.value.sessionId)).toBeUndefined();
});
it("does not resume a paused spawn after its parent session detached", async () => {
const bindings = new FakeDeviceManager();
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
});
expect(started.ok).toBe(true);
if (!started.ok) return;
bindings.device.session.markDetachedWithoutSignal();
const closed = await manager.closeSession(started.value.sessionId);
expect(closed.ok).toBe(true);
expect(bindings.device.resumed).toEqual([]);
expect(manager.status(started.value.sessionId)).toBeUndefined();
});
it("uses successful parent-session detach to release a script after unload fails", async () => {
const { bindings, manager, started } = await startLocalAttachedSession();
expect(started.ok).toBe(true);
if (!started.ok) return;
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('ready');",
});
expect(loaded.ok).toBe(true);
const script = bindings.device.session.scripts[0];
expect(script).toBeDefined();
if (script === undefined) return;
script.unloadFailureCount = 1;
const closed = await manager.closeSession(started.value.sessionId);
expect(closed.ok).toBe(true);
expect(script.isDestroyed).toBe(true);
expect(manager.status(started.value.sessionId)).toBeUndefined();
});
it("keeps a paused spawn paused when script unload and session detach fail", async () => {
const bindings = new FakeDeviceManager();
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
});
expect(started.ok).toBe(true);
if (!started.ok) return;
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('ready');",
});
expect(loaded.ok).toBe(true);
const script = bindings.device.session.scripts[0];
expect(script).toBeDefined();
if (script === undefined) return;
script.unloadFailureCount = 1;
bindings.device.session.detachFailureCount = 1;
const closed = await manager.closeSession(started.value.sessionId);
expect(closed.ok).toBe(false);
expect(bindings.device.resumed).toEqual([]);
expect(manager.status(started.value.sessionId)?.scripts).toHaveLength(1);
});
it("refuses new session work after incomplete close while retaining cleanup for retry", async () => {
const bindings = new FakeDeviceManager();
bindings.device.session.detachFailureCount = 1;
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
onTestFinished(() => manager.closeAll());
const started = await manager.startSession({ mode: "attach", pid: 512 });
if (!started.ok) throw started.error;
const id = started.value.sessionId;
const closing = manager.closeSession(id);
const loading = manager.loadScript(id, {
sourceKind: "inline",
source: "send('after close');",
});
const closed = await closing;
if (closed.ok) throw new Error("Expected incomplete close");
expect(closed.error.cleanupIncomplete).toBe(true);
const loaded = await loading;
expect(loaded.ok).toBe(false);
if (loaded.ok) throw new Error("Expected cleanup-only session refusal");
expect(projectAnalysisError(loaded.error).remediation?.action).toContain(
"close_frida_session",
);
expect(bindings.device.session.scripts).toHaveLength(0);
expect((await manager.closeSession(id)).ok).toBe(true);
expect(closed.error.cleanupIncomplete).toBe(false);
expect(manager.status(id)).toBeUndefined();
});
it("refuses new session work after incomplete close without blocking cleanup resume", async () => {
const bindings = new FakeDeviceManager();
bindings.device.session.detachFailureCount = 1;
bindings.device.resumeFailureCount = 1;
const resume = vi.spyOn(bindings.device, "resume");
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
onTestFinished(async () => {
try {
await manager.closeAll();
} finally {
resume.mockRestore();
}
});
const started = await manager.startSession({
mode: "spawn",
program: "/tmp/fixture",
});
if (!started.ok) throw started.error;
const id = started.value.sessionId;
const closed = await manager.closeSession(id);
if (closed.ok) throw new Error("Expected incomplete close");
expect(resume).toHaveBeenCalledTimes(1);
const resumed = await manager.resumeSession(id);
expect(resumed.ok).toBe(false);
expect(resume).toHaveBeenCalledTimes(1);
expect((await manager.closeSession(id)).ok).toBe(true);
expect(resume).toHaveBeenCalledTimes(2);
expect(bindings.device.resumed).toEqual([512]);
expect(closed.error.cleanupIncomplete).toBe(false);
});
it("retries resuming a spawn after detach succeeded but resume failed", async () => {
const bindings = new FakeDeviceManager();
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
});
expect(started.ok).toBe(true);
if (!started.ok) return;
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('ready');",
});
expect(loaded.ok).toBe(true);
bindings.device.resumeFailureCount = 1;
const firstClose = await manager.closeSession(started.value.sessionId);
expect(firstClose.ok).toBe(false);
expect(bindings.device.session.isDetached()).toBe(true);
expect(manager.status(started.value.sessionId)).toBeDefined();
const retry = await manager.closeSession(started.value.sessionId);
expect(retry.ok).toBe(true);
expect(bindings.device.resumed).toEqual([512]);
expect(manager.status(started.value.sessionId)).toBeUndefined();
});
it("keeps a remote device while a spawned target resume is pending", async () => {
const bindings = new FakeDeviceManager();
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "spawn",
remote: { address: "127.0.0.1:27042" },
program: "/tmp/fixture",
});
expect(started.ok).toBe(true);
if (!started.ok) return;
bindings.device.resumeFailureCount = 1;
const firstClose = await manager.closeSession(started.value.sessionId);
expect(firstClose.ok).toBe(false);
expect(bindings.removedRemote).toEqual([]);
const retry = await manager.closeSession(started.value.sessionId);
expect(retry.ok).toBe(true);
expect(bindings.device.resumed).toEqual([512]);
expect(bindings.removedRemote).toEqual(["127.0.0.1:27042"]);
});
it("keeps a remote device while script and session cleanup are pending", async () => {
const bindings = new FakeDeviceManager();
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const started = await manager.startSession({
mode: "attach",
remote: { address: "127.0.0.1:27042" },
pid: 512,
});
expect(started.ok).toBe(true);
if (!started.ok) return;
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('ready');",
});
expect(loaded.ok).toBe(true);
const script = bindings.device.session.scripts[0];
expect(script).toBeDefined();
if (script === undefined) return;
script.unloadFailureCount = 1;
bindings.device.session.detachFailureCount = 1;
const firstClose = await manager.closeSession(started.value.sessionId);
expect(firstClose.ok).toBe(false);
expect(bindings.removedRemote).toEqual([]);
const retry = await manager.closeSession(started.value.sessionId);
expect(retry.ok).toBe(true);
expect(script.isDestroyed).toBe(true);
expect(bindings.removedRemote).toEqual(["127.0.0.1:27042"]);
});
});
it("does not release a newer remote lease when retrying an older session close", async () => {
const bindings = new FakeDeviceManager();
bindings.removeRemoteFailureCount = 1;
const manager = new FridaInstrumentationManager({ deviceManager: bindings });
const remote = { address: "127.0.0.1:27042" };
const first = await manager.startSession({
mode: "attach",
remote,
pid: 512,
});
expect(first.ok).toBe(true);
if (!first.ok) return;
const failedClose = await manager.closeSession(first.value.sessionId);
expect(failedClose.ok).toBe(false);
const second = await manager.startSession({
mode: "attach",
remote,
pid: 512,
});
expect(second.ok).toBe(true);
if (!second.ok) return;
const retry = await manager.closeSession(first.value.sessionId);
expect(retry.ok).toBe(true);
expect(bindings.removedRemote).toEqual([]);
expect(manager.status(second.value.sessionId)).toBeDefined();
const secondClose = await manager.closeSession(second.value.sessionId);
expect(secondClose.ok).toBe(true);
expect(bindings.removedRemote).toEqual([remote.address]);
});
it("attributes messages to their producing script and returns each load's messages", async () => {
const { bindings, manager, started } = await startLocalAttachedSession();
expect(started.ok).toBe(true);
if (!started.ok) return;
bindings.device.session.nextPayload = { tag: "A" };
const first = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send({tag: 'A'});",
});
expect(first.ok).toBe(true);
if (!first.ok) return;
bindings.device.session.nextPayload = { tag: "B" };
const second = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send({tag: 'B'});",
});
expect(second.ok).toBe(true);
if (!second.ok) return;
expect(first.value.messages).toEqual([
expect.objectContaining({
script_id: first.value.scriptId,
payload: { tag: "A" },
}),
]);
expect(second.value.messages).toEqual([
expect.objectContaining({
script_id: second.value.scriptId,
payload: { tag: "B" },
}),
]);
expect(manager.status(started.value.sessionId)?.messages).toEqual([
expect.objectContaining({ script_id: first.value.scriptId }),
expect.objectContaining({ script_id: second.value.scriptId }),
]);
await manager.closeAll();
});
it("retains Frida error source coordinates in script and session evidence", async () => {
const { bindings, manager, started } = await startLocalAttachedSession();
expect(started.ok).toBe(true);
if (!started.ok) return;
bindings.device.session.nextMessage = {
type: "error",
description: "instrumentation failed",
stack: "Error: instrumentation failed",
fileName: "agent.js",
lineNumber: 7,
columnNumber: 3,
};
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "throw new Error('instrumentation failed');",
});
expect(loaded.ok).toBe(true);
if (loaded.ok) {
expect(loaded.value.messages[0]).toMatchObject({
type: "error",
file_name: "agent.js",
line_number: 7,
column_number: 3,
});
}
expect(manager.status(started.value.sessionId)?.messages[0]).toMatchObject({
file_name: "agent.js",
line_number: 7,
column_number: 3,
});
bindings.device.session.nextMessage = {
type: "error",
description: "location unavailable",
stack: "Error: location unavailable",
};
const withoutLocation = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "throw new Error('location unavailable');",
});
expect(withoutLocation.ok).toBe(true);
if (withoutLocation.ok) {
expect(withoutLocation.value.messages[0]).not.toHaveProperty("file_name");
expect(withoutLocation.value.messages[0]).not.toHaveProperty("line_number");
expect(withoutLocation.value.messages[0]).not.toHaveProperty(
"column_number",
);
}
await manager.closeAll();
});
it("does not carry earlier script truncation into a complete later load", async () => {
const { bindings, manager, started } = await startLocalAttachedSession();
expect(started.ok).toBe(true);
if (!started.ok) return;
const first = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('A');",
});
expect(first.ok).toBe(true);
if (!first.ok) return;
const firstScript = bindings.device.session.scripts[0];
expect(firstScript).toBeDefined();
if (firstScript === undefined) return;
for (let index = 0; index < 800; index += 1)
firstScript.message.emit(
{ type: "send", payload: "x".repeat(32_700) },
null,
);
bindings.device.session.nextPayload = { tag: "B" };
const second = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('B');",
});
expect(second.ok).toBe(true);
if (second.ok) {
expect(second.value.messages).toHaveLength(1);
expect(second.value.messagesTruncated).toBe(false);
}
expect(manager.status(started.value.sessionId)?.messagesTruncated).toBe(true);
await manager.closeAll();
});
it("reports truncation when that script loses its own message", async () => {
const { bindings, manager, started } = await startLocalAttachedSession();
const session = bindings.device.session;
expect(started.ok).toBe(true);
if (!started.ok) return;
session.nextPayload = { tag: "A" };
const first = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send({tag: 'A'});",
});
expect(first.ok).toBe(true);
if (!first.ok) return;
let finishSecondLoad: (() => void) | undefined;
let markSecondLoadStarted: (() => void) | undefined;
const secondLoadGate = new Promise<void>((resolve) => {
finishSecondLoad = resolve;
});
const secondLoadStarted = new Promise<void>((resolve) => {
markSecondLoadStarted = resolve;
});
session.nextPayload = { tag: "B2" };
session.scriptLoadGate = secondLoadGate;
session.onScriptLoadStarted = () => markSecondLoadStarted?.();
const secondLoading = manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send({tag: 'B2'});",
});
await secondLoadStarted;
const secondScript = session.scripts[1];
expect(secondScript).toBeDefined();
if (secondScript === undefined) return;
secondScript.message.emit({ type: "send", payload: { tag: "B1" } }, null);
const firstScript = session.scripts[0];
expect(firstScript).toBeDefined();
if (firstScript === undefined) return;
for (let index = 0; index < 800; index += 1)
firstScript.message.emit(
{ type: "send", payload: "x".repeat(32_700) },
null,
);
finishSecondLoad?.();
const second = await secondLoading;
expect(second.ok).toBe(true);
if (second.ok) {
expect(second.value.messages).toContainEqual(
expect.objectContaining({
script_id: second.value.scriptId,
payload: { tag: "B2" },
}),
);
expect(second.value.messagesTruncated).toBe(true);
}
expect(manager.status(started.value.sessionId)?.messagesTruncated).toBe(true);
expect(manager.status(started.value.sessionId)?.messages).toContainEqual(
expect.objectContaining({
script_id: second.ok ? second.value.scriptId : "",
payload: { tag: "B2" },
}),
);
await manager.closeAll();
});
it("retains the newest contiguous messages across repeated budget evictions", async () => {
const { bindings, manager, started } = await startLocalAttachedSession();
expect(started.ok).toBe(true);
if (!started.ok) return;
bindings.device.session.nextPayload = { index: -1 };
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send({index: -1});",
});
expect(loaded.ok).toBe(true);
const script = bindings.device.session.scripts[0];
expect(script).toBeDefined();
if (script === undefined) return;
// About 500 of these fit the 16 MiB budget, so 2,000 sends evict and
// compact the queue several times.
const filler = "x".repeat(32_000);
for (let index = 0; index < 2_000; index += 1)
script.message.emit({ type: "send", payload: { index, filler } }, null);
const status = manager.status(started.value.sessionId);
expect(status?.messagesTruncated).toBe(true);
const indexes = (status?.messages ?? []).map(
(message) => (message as { payload: { index: number } }).payload.index,
);
expect(indexes.length).toBeGreaterThan(400);
expect(indexes.at(-1)).toBe(1_999);
expect(indexes).toEqual(
Array.from(
{ length: indexes.length },
(_, offset) => 2_000 - indexes.length + offset,
),
);
await manager.closeAll();
});
it("serializes a final remote release against a new same-endpoint session", async () => {
const bindings = new FakeDeviceManager();
let enteredRemoval: (() => void) | undefined;
const removalEntered = new Promise<void>((resolve) => {
enteredRemoval = resolve;
});
let finishRemoval: (() => void) | undefined;
const removalGate = new Promise<void>((resolve) => {
finishRemoval = resolve;
});
bindings.removeRemoteDevice = async (address) => {
enteredRemoval?.();
await removalGate;
bindings.removedRemote.push(address);
};
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
const remote = { address: "127.0.0.1:27042", token: "same-token" };
const first = await manager.startSession({
mode: "attach",
remote,
pid: 512,
});
expect(first.ok).toBe(true);
if (!first.ok) return;
const closing = manager.closeSession(first.value.sessionId);
await removalEntered;
let secondStarted = false;
const secondPromise = manager
.startSession({ mode: "attach", remote, pid: 512 })
.then((result) => {
secondStarted = true;
return result;
});
await Promise.resolve();
expect(secondStarted).toBe(false);
finishRemoval?.();
expect((await closing).ok).toBe(true);
const second = await secondPromise;
expect(second.ok).toBe(true);
if (second.ok) await manager.closeSession(second.value.sessionId);
expect(bindings.removedRemote).toEqual([
"127.0.0.1:27042",
"127.0.0.1:27042",
]);
});
it("redacts remote credentials and maps a missing optional binding", async () => {
const secret = "synthetic-secret-token";
const remoteManager = new FakeDeviceManager();
remoteManager.addRemoteDevice = async () => {
throw new Error(`authorization rejected: ${secret}`);
};
const remote = new FridaInstrumentationManager({
deviceManager: remoteManager,
});
const failed = await remote.listProcesses({
remote: {
address: "wss://host/path?token=synthetic-url-token",
token: secret,
},
});
expect(failed.ok).toBe(false);
if (!failed.ok) {
expect(failed.error.message).not.toContain(secret);
expect(failed.error.message).not.toContain("synthetic-url-token");
expect(JSON.stringify(projectAnalysisError(failed.error))).not.toContain(
"synthetic-url-token",
);
}
const missing = new FridaInstrumentationManager({
deviceManagerLoader: async () => {
throw new Error("Cannot find package 'frida'");
},
});
const unavailable = await missing.listDevices();
expect(unavailable.ok).toBe(false);
if (!unavailable.ok)
expect(unavailable.error.message).toContain("Cannot find package 'frida'");
});
it("retains the selected device or credential-free remote address on discovery and startup failures", async () => {
const bindings = new FakeDeviceManager();
bindings.getDeviceById = async () => {
throw new Error("Device not found");
};
bindings.addRemoteDevice = async () => {
throw new Error("Unable to connect to remote frida-server");
};
const manager = new FridaInstrumentationManager({ deviceManager: bindings });
const device = await manager.listProcesses({ deviceId: "missing-device" });
expect(device.ok).toBe(false);
if (!device.ok) {
expect(projectAnalysisError(device.error)).toMatchObject({
code: "provider_timeout",
details: { diagnostics: { device_id: "missing-device" } },
});
}
const remote = { address: "127.0.0.1:27042", token: "secret-token" };
for (const result of [
await manager.listProcesses({ remote }),
await manager.listDevices(remote),
await manager.startSession({ mode: "attach", remote, pid: 512 }),
]) {
expect(result.ok).toBe(false);
if (result.ok) continue;
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "capability_unavailable",
details: { diagnostics: { remote_address: remote.address } },
});
expect(JSON.stringify(projected)).not.toContain(remote.token);
expect(projected.remediation.action).toContain("selected Frida server");
}
});
it("redacts credentials from inherited typed failure diagnostics", async () => {
const bindings = new FakeDeviceManager();
bindings.addRemoteDevice = async () => {
throw new AnalysisTimeoutError("frida_device_lookup", 1000, {
diagnostics: { detail: "token=synthetic-secret-token" },
});
};
const manager = new FridaInstrumentationManager({ deviceManager: bindings });
const result = await manager.listProcesses({
remote: { address: "127.0.0.1:27042", token: "synthetic-secret-token" },
});
expect(result.ok).toBe(false);
if (result.ok) return;
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "provider_timeout",
details: { diagnostics: { remote_address: "127.0.0.1:27042" } },
});
expect(JSON.stringify(projected)).not.toContain("synthetic-secret-token");
});
it("does not describe a connected device's enumeration failure as a connection failure", async () => {
const bindings = new FakeDeviceManager();
bindings.device.enumerateProcesses = async () => {
throw new Error("process enumeration denied");
};
const manager = new FridaInstrumentationManager({ deviceManager: bindings });
const result = await manager.listProcesses({
remote: { address: "127.0.0.1:27042" },
});
expect(result.ok).toBe(false);
if (result.ok) return;
const projected = projectAnalysisError(result.error);
expect(projected.details?.diagnostics).toMatchObject({
remote_address: "127.0.0.1:27042",
});
expect(projected.remediation.action).not.toContain("selected Frida server");
});
it("clears current binding failure after recovery", async () => {
const bindings = new FakeDeviceManager();
let attempts = 0;
const manager = new FridaInstrumentationManager({
deviceManagerLoader: async () => {
attempts += 1;
if (attempts === 1) throw new Error("transient binding load failure");
return bindings;
},
});
const failed = await manager.listDevices();
expect(failed.ok).toBe(false);
if (!failed.ok)
expect(failed.error.message).toContain("transient binding load failure");
expect(manager.availabilityFailure()?.reason).toBe(
"transient binding load failure",
);
const recovered = await manager.listDevices();
expect(recovered.ok).toBe(true);
expect(manager.availabilityFailure()).toBeUndefined();
});
it("keeps remote option conflicts as actionable invalid input", async () => {
const manager = new FridaInstrumentationManager({
deviceManager: new FakeDeviceManager(),
});
const retained = await manager.startSession({
mode: "attach",
remote: { address: "127.0.0.1:27042", token: "first-token" },
pid: 512,
});
expect(retained.ok).toBe(true);
if (!retained.ok) return;
const conflicting = [
await manager.listDevices({
address: "127.0.0.1:27042",
token: "other-token",
}),
await manager.listProcesses({
remote: { address: "127.0.0.1:27042", token: "other-token" },
}),
await manager.startSession({
mode: "attach",
remote: { address: "127.0.0.1:27042", token: "other-token" },
pid: 512,
}),
];
for (const result of conflicting) {
expect(result.ok).toBe(false);
if (result.ok) continue;
if (!(result.error instanceof AnalysisInputError)) throw result.error;
expect(result.error.operation).not.toBe("frida_remote_connection");
expect(result.error.issues).toContainEqual(
expect.objectContaining({
path: ["remote"],
reason: "invalid_value",
message: expect.stringContaining("different connection options"),
}),
);
expect(result.error.message).not.toContain("other-token");
}
await manager.closeAll();
});
it("redacts remote credentials echoed by a target script", async () => {
const token = "synthetic-token-from-auth";
const certificate = "synthetic-certificate-from-auth";
const bindings = new FakeDeviceManager();
bindings.device.session.nextPayload = { token, certificate };
const manager = new FridaInstrumentationManager({ deviceManager: bindings });
const started = await manager.startSession({
mode: "attach",
remote: { address: "127.0.0.1:27042", token, certificate },
pid: 512,
});
expect(started.ok).toBe(true);
if (!started.ok) return;
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('credential payload');",
});
expect(loaded.ok).toBe(true);
if (loaded.ok) {
const serialized = JSON.stringify(loaded.value.messages);
expect(serialized).not.toContain(token);
expect(serialized).not.toContain(certificate);
expect(serialized).toContain("[redacted]");
}
await manager.closeAll();
});
it.each([
{
label: "colliding redacted names",
declared: true,
secondName: "[redacted]",
truncated: true,
},
{
label: "distinct redacted names",
declared: true,
secondName: "other",
truncated: false,
},
{
label: "same marker spelling without a declared secret",
declared: false,
secondName: "[redacted]",
truncated: false,
},
])(
"preserves captured object facts with $label",
async ({ declared, secondName, truncated }) => {
const token = "synthetic-key-token";
const bindings = new FakeDeviceManager();
bindings.device.session.nextPayload = {
[token]: "first observation",
[secondName]: "second observation",
ordinary: "third observation",
};
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
onTestFinished(() => manager.closeAll());
const started = await manager.startSession({
mode: "attach",
remote: { address: "127.0.0.1:27042", ...(declared ? { token } : {}) },
pid: 512,
});
if (!started.ok) throw started.error;
const loaded = await new FridaInstrumentationService(manager).loadScript(
started.value.sessionId,
{ sourceKind: "inline", source: "send(payload)" },
);
if (!loaded.ok) throw loaded.error;
const result = jsonObjectSchema.parse(loaded.value.normalized_result);
if (!Array.isArray(result.messages))
throw new Error("Expected captured messages");
const message = jsonObjectSchema.parse(result.messages[0]);
expect(message.payload).toEqual(
truncated
? { "[redacted]": "first observation" }
: {
[declared ? "[redacted]" : token]: "first observation",
[secondName]: "second observation",
ordinary: "third observation",
},
);
expect(message.type).toBe("send");
expect(message.script_id).toBe(result.script_id);
expect(message.payload_truncated ?? false).toBe(truncated);
if (truncated)
expect(message.payload_capture_limit).toBe("unrepresentable");
else expect(message).not.toHaveProperty("payload_capture_limit");
expect(
loaded.value.limitations.some((text) =>
text.includes("could not be represented as JSON"),
),
).toBe(truncated);
if (declared) expect(JSON.stringify(loaded.value)).not.toContain(token);
else expect(JSON.stringify(loaded.value)).toContain(token);
},
);
it.each([
{ captureLimits: undefined, retained: 32_768, truncated: true },
{
captureLimits: { message_text_bytes: 65_536 },
retained: 65_536,
truncated: true,
},
{
captureLimits: { message_text_bytes: 1_048_576 },
retained: 1_000_000,
truncated: false,
},
])(
"honors session text capture policy $captureLimits before observing messages",
async ({ captureLimits, retained, truncated }) => {
const bindings = new FakeDeviceManager();
bindings.device.session.nextDescription = "d".repeat(1_000_000);
bindings.device.session.nextStack = "s".repeat(1_000_000);
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
onTestFinished(() => manager.closeAll());
const started = await manager.startSession({
mode: "attach",
deviceId: "local",
pid: 512,
...(captureLimits === undefined ? {} : { captureLimits }),
});
if (!started.ok) throw started.error;
const service = new FridaInstrumentationService(manager);
const loaded = await service.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('metadata');",
});
if (!loaded.ok) throw loaded.error;
const result = loaded.value.normalized_result as {
messages: {
description: string;
stack: string;
description_truncated?: boolean;
stack_truncated?: boolean;
}[];
};
const message = result.messages[0];
expect(message?.description).toHaveLength(retained);
expect(message?.stack).toHaveLength(retained);
expect(message?.description_truncated ?? false).toBe(truncated);
expect(message?.stack_truncated ?? false).toBe(truncated);
if (truncated) {
expect(loaded.value.limitations.join("\n")).toContain(
`capture_limits.message_text_bytes (${retained} bytes)`,
);
expect(loaded.value.limitations.join("\n")).toContain(
"Already discarded observations cannot be recovered",
);
}
},
);
it.each([
{ nodes: 2, truncated: true },
{ nodes: 4, truncated: false },
])(
"captures a selected JSON payload with json_nodes=$nodes",
async ({ nodes, truncated }) => {
const bindings = new FakeDeviceManager();
bindings.device.session.nextPayload = [1, 2, 3];
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
onTestFinished(() => manager.closeAll());
const started = await manager.startSession({
mode: "attach",
pid: 512,
captureLimits: { json_nodes: nodes },
});
if (!started.ok) throw started.error;
const service = new FridaInstrumentationService(manager);
const loaded = await service.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send([1,2,3])",
});
if (!loaded.ok) throw loaded.error;
const message = (
loaded.value.normalized_result as { messages: Record<string, unknown>[] }
).messages[0];
expect(message?.payload_truncated ?? false).toBe(truncated);
if (truncated) {
expect(message?.payload_capture_limit).toBe("json_nodes");
expect(loaded.value.limitations.join("\n")).toContain(
"capture_limits.json_nodes (2 nodes)",
);
} else expect(message?.payload).toEqual([1, 2, 3]);
},
);
it.each([
{ sessionBytes: 200_000, truncated: true },
{ sessionBytes: 300_000, truncated: false },
])(
"accounts base64 against selected session bytes=$sessionBytes",
async ({ sessionBytes, truncated }) => {
const bindings = new FakeDeviceManager();
const manager = new FridaInstrumentationManager({
deviceManager: bindings,
});
onTestFinished(() => manager.closeAll());
const started = await manager.startSession({
mode: "attach",
pid: 512,
captureLimits: { session_bytes: sessionBytes },
});
if (!started.ok) throw started.error;
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send(null, new ArrayBuffer(65536))",
});
if (!loaded.ok) throw loaded.error;
bindings.device.session.scripts[0]?.message.emit(
{ type: "send", payload: null },
Buffer.alloc(65_536),
);
const observed = manager.status(started.value.sessionId);
const message = observed?.messages.at(-1) as Record<string, unknown>;
expect(message.data_truncated ?? false).toBe(truncated);
if (truncated) {
expect(message.data_capture_limit).toBe("session_bytes");
expect(message.data_bytes).toBe(65_536);
} else
expect(Buffer.from(message.data_base64 as string, "base64")).toHaveLength(
65_536,
);
},
);
it("rejects invalid capture policy before attaching or spawning", async () => {
const bindings = new FakeDeviceManager();
const manager = new FridaInstrumentationManager({ deviceManager: bindings });
const started = await manager.startSession({
mode: "attach",
pid: 512,
captureLimits: { json_nodes: 0 },
});
expect(started.ok).toBe(false);
if (!started.ok) expect(started.error).toBeInstanceOf(AnalysisInputError);
expect(bindings.device.attachedTargets).toEqual([]);
await manager.closeAll();
});
it.each([
{
name: "ASCII at the limit",
input: "x".repeat(32_768),
retained: "x".repeat(32_768),
truncated: false,
},
{
name: "a trailing unpaired high surrogate",
input: "x".repeat(32_765) + "\ud800",
retained: "x".repeat(32_765),
truncated: true,
},
{
name: "a trailing unpaired low surrogate",
input: "x".repeat(32_765) + "\udc00",
retained: "x".repeat(32_765),
truncated: true,
},
{
name: "a valid surrogate pair at the limit",
input: "x".repeat(32_764) + "\ud800\udc00",
retained: "x".repeat(32_764) + "\ud800\udc00",
truncated: false,
},
{
name: "repeated unpaired high surrogates",
input: "\ud800".repeat(10_922),
retained: "\ud800".repeat(5_461),
truncated: true,
},
])(
"bounds JSON metadata bytes for $name",
async ({ input, retained, truncated }) => {
const { bindings, manager, started } = await startLocalAttachedSession();
onTestFinished(() => manager.closeAll());
if (!started.ok) throw started.error;
bindings.device.session.nextMessage = {
type: "error",
description: input,
stack: input,
fileName: input,
};
const loaded = await manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "// inert fixture",
});
if (!loaded.ok) throw loaded.error;
const message = jsonObjectSchema.parse(loaded.value.messages[0]);
for (const field of ["description", "stack", "file_name"]) {
expect(
Buffer.byteLength(JSON.stringify(message[field])) - 2,
).toBeLessThanOrEqual(32_768);
expect(message[field]).toBe(retained);
expect(message[`${field}_truncated`] ?? false).toBe(truncated);
}
expect(loaded.value.messagesTruncated).toBe(false);
expect(manager.status(started.value.sessionId)).toMatchObject({
messages: [message],
messagesTruncated: false,
});
},
);
it("serializes script loading against concurrent close and unloads the loaded script", async () => {
const bindings = new FakeDeviceManager();
let finishScriptLoad: (() => void) | undefined;
const scriptLoadGate = new Promise<void>((resolve) => {
finishScriptLoad = resolve;
});
let markScriptLoadStarted: (() => void) | undefined;
const scriptLoadStarted = new Promise<void>((resolve) => {
markScriptLoadStarted = resolve;
});
bindings.device.session.scriptLoadGate = scriptLoadGate;
bindings.device.session.onScriptLoadStarted = () => markScriptLoadStarted?.();
const manager = new FridaInstrumentationManager({ deviceManager: bindings });
const started = await manager.startSession({
mode: "attach",
deviceId: "local",
pid: 512,
});
expect(started.ok).toBe(true);
if (!started.ok) return;
const loading = manager.loadScript(started.value.sessionId, {
sourceKind: "inline",
source: "send('loaded');",
});
await scriptLoadStarted;
let closed = false;
const closing = manager.closeSession(started.value.sessionId);
void closing.then(() => {
closed = true;
});
expect(manager.closeSession(started.value.sessionId)).toBe(closing);
await Promise.resolve();
expect(closed).toBe(false);
finishScriptLoad?.();
expect((await loading).ok).toBe(true);
expect((await closing).ok).toBe(true);
expect(bindings.device.session.scripts[0]?.isDestroyed).toBe(true);
expect(bindings.device.session.isDetached()).toBe(true);
});
it("drains startup when the provider loader initiates shutdown", async () => {
const bindings = new FakeDeviceManager();
let closing: Promise<void> | undefined;
const manager = new FridaInstrumentationManager({
deviceManagerLoader: async () => {
closing = manager.closeAll();
return bindings;
},
});
const started = await manager.startSession({
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
});
if (!started.ok) throw started.error;
expect(closing).toBeDefined();
await closing;
expect(bindings.device.session.isDetached()).toBe(true);
expect(bindings.device.resumed).toEqual([512]);
expect(manager.status(started.value.sessionId)).toBeUndefined();
});
it("names the unknown session or script ID it rejects", async () => {
const { manager, started } = await startLocalAttachedSession();
if (!started.ok) throw started.error;
const missing = "00000000-0000-4000-8000-000000000009";
const sessionIssue = {
path: ["session_id"],
reason: "invalid_value",
message: `No open Frida session ${missing}; start one with start_frida_session.`,
};
for (const result of [
await manager.closeSession(missing),
await manager.resumeSession(missing),
await manager.loadScript(missing, { sourceKind: "inline", source: "" }),
await manager.unloadScript(missing, missing),
])
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError", issues: [sessionIssue] },
});
expect(
await manager.unloadScript(started.value.sessionId, missing),
).toMatchObject({
ok: false,
error: {
issues: [
{
path: ["script_id"],
reason: "invalid_value",
message: `No loaded Frida script ${missing} in this session.`,
},
],
},
});
await manager.closeAll();
});
it.each([false, true])(
"retains messages emitted during cancelled script cleanup after native rejection=%s",
async (rejectNative) => {
const { bindings, manager, started } = await startLocalAttachedSession();
onTestFinished(() => manager.closeAll());
if (!started.ok) throw started.error;
const session = bindings.device.session;
const entered = createDeferred<void>();
const held = createDeferred<void>();
onTestFinished(() => held.resolve());
session.onScriptLoadStarted = entered.resolve;
session.scriptLoadGate = held.promise;
const nativeFailure = new Error("native load failed");
if (rejectNative) session.nextLoadFailure = nativeFailure;
const controller = new AbortController();
const reason = new Error("cancel before native completion");
const loading = manager.loadScript(
started.value.sessionId,
{ sourceKind: "inline", source: "send('marker')" },
controller.signal,
);
await entered.promise;
const script = session.scripts[0];
if (script === undefined) throw new Error("Missing admitted script");
const unload = script.unload.bind(script);
script.unload = async () => {
script.message.emit({ type: "send", payload: "during cleanup" }, null);
script.message.emit(
{ type: "send", description: "x".repeat(40_000) },
null,
);
await unload();
};
controller.abort(reason);
held.resolve();
const loaded = await loading;
if (loaded.ok) throw new Error("Cancelled load succeeded");
expect(loaded.error).toBeInstanceOf(FridaScriptLoadCancelledError);
if (!(loaded.error instanceof FridaScriptLoadCancelledError))
throw loaded.error;
expect(loaded.error.cause).toBe(reason);
expect(loaded.error.script.messages).toContainEqual(
expect.objectContaining({ payload: "during cleanup" }),
);
expect(loaded.error.script.messagesTruncated).toBe(false);
expect(loaded.error.script.messages).toContainEqual(
expect.objectContaining({ description_truncated: true }),
);
expect(loaded.error.diagnostics).toEqual(
rejectNative ? { native_failure: nativeFailure.message } : undefined,
);
expect(loaded.error.cleanupIncomplete).toBe(false);
expect(script.isDestroyed).toBe(true);
expect(manager.status(started.value.sessionId)?.scripts).toEqual([]);
expect(session.isDetached()).toBe(false);
},
);
it("preserves a native rejection observed before cancellation during script cleanup", async () => {
const { bindings, manager, started } = await startLocalAttachedSession();
onTestFinished(() => manager.closeAll());
if (!started.ok) throw started.error;
const session = bindings.device.session;
const entered = createDeferred<void>();
const held = createDeferred<void>();
onTestFinished(() => held.resolve());
const create = session.createScript.bind(session);
session.createScript = async () => {
const script = await create();
script.unload = async () => {
entered.resolve();
await held.promise;
const owned = session.scripts[0];
if (owned !== undefined) owned.isDestroyed = true;
};
return script;
};
const failure = new Error("native rejection first");
session.nextLoadFailure = failure;
const controller = new AbortController();
const loading = manager.loadScript(
started.value.sessionId,
{ sourceKind: "inline", source: "void 0" },
controller.signal,
);
await entered.promise;
controller.abort(new Error("late abort during cleanup"));
held.resolve();
const result = await loading;
if (result.ok) throw new Error("Failed load succeeded");
expect(result.error).toBeInstanceOf(AnalysisCapabilityUnavailableError);
expect(result.error.cause).toBe(failure);
expect(result.error.cleanupIncomplete).toBe(false);
expect(manager.status(started.value.sessionId)?.scripts).toEqual([]);
});
it("restores the original load failure only after fallback cleanup confirms its exact owners", async () => {
const bindings = new FakeDeviceManager();
bindings.device.session.nextLoadFailure = new Error(
"primary native load failed",
);
bindings.device.session.nextUnloadFailureCount = 3;
bindings.device.session.detachFailureCount = 2;
const manager = new FridaInstrumentationManager({ deviceManager: bindings });
const service = new FridaInstrumentationService(manager);
const result = await service.instrument({
mode: "attach",
pid: 512,
deviceId: "local",
source: { sourceKind: "inline", source: "send(1)" },
durationMs: 0,
});
if (result.ok) throw new Error("Expected native load failure");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cleanup_incomplete",
});
const closed = await service.closeAll().catch((cause: unknown) => cause);
expect(closed).toBeInstanceOf(AnalysisError);
expect(result.error.cleanupIncomplete).toBe(true);
await service.closeAll();
expect(result.error.cleanupIncomplete).toBe(false);
expect(projectAnalysisError(result.error)).toMatchObject({
code: "capability_unavailable",
details: {
operation: "load_frida_script",
reason: "primary native load failed",
},
});
});
it("retries only unresolved sessions and resolves the typed close aggregate", async () => {
const bindings = new FakeDeviceManager();
const failing = new FakeSession();
failing.detachFailureCount = 1;
const successful = new FakeSession();
const sessions = [failing, successful];
const calls = [0, 0];
for (const [index, session] of sessions.entries()) {
const detach = session.detach.bind(session);
session.detach = async () => {
calls[index] = (calls[index] ?? 0) + 1;
await detach();
};
}
bindings.device.attach = async () => {
const session = sessions.shift();
if (session === undefined) throw new Error("Unexpected extra attach");
return session;
};
const manager = new FridaInstrumentationManager({ deviceManager: bindings });
await manager.startSession({ mode: "attach", pid: 512 });
await manager.startSession({ mode: "attach", pid: 513 });
const failure = await manager.closeAll().catch((cause: unknown) => cause);
if (!(failure instanceof AnalysisError))
throw new Error("Expected typed cleanup aggregate");
expect(failure.cleanupIncomplete).toBe(true);
expect(failure.cleanupResources).toHaveLength(1);
expect(calls).toEqual([1, 1]);
await manager.closeAll();
expect(calls).toEqual([2, 1]);
expect(failure.cleanupIncomplete).toBe(false);
});
// Malformed or partial observations do not establish the session's policy.
it.each([{}, { session_bytes: 12345 }, { session_bytes: -1 }])(
"keeps observed capture policy unknown for %j",
(capture_limits) => {
const text = fridaCaptureLimitations({
capture_limits,
messages_truncated: true,
messages: [{ type_truncated: true, description_truncated: true }],
}).join("\n");
expect(text).toContain("selected value is unknown");
for (const invented of ["16777216", "4096", "32768"])
expect(text).not.toContain(invented);
expect(text).toContain(
"Already discarded observations cannot be recovered",
);
},
);