From 629ffc3872cdf2f3dabbe053890ac6a4103fdd1c Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Fri, 17 Jul 2026 00:48:04 +0800 Subject: [PATCH] feat(ghidra): add authenticated Windows loopback transport Keep private Unix sockets on supported POSIX hosts and add a Windows P0 transport that binds the Java bridge to IPv4 loopback on an ephemeral port. Publish only a strict, token-free endpoint record through an atomic rename, validate it before connecting, and retain the same authenticated request protocol, bounded queue, handshake, cancellation, and shutdown semantics across both transports. --- bridge/ghidra/ReaGhidraBridge.java | 83 ++++++++++++++-- src/ghidra/GhidraClient.ts | 40 +++++--- src/ghidra/GhidraClientTypes.ts | 2 + src/ghidra/GhidraDiagnostics.ts | 9 +- src/ghidra/GhidraLauncher.ts | 9 +- src/ghidra/GhidraProvider.ts | 4 + src/ghidra/GhidraSocketConnection.ts | 14 ++- src/ghidra/GhidraTransport.ts | 129 +++++++++++++++++++++++++ tests/fixtures/fakeGhidra.mjs | 31 ++++-- tests/ghidraBridgeTruthfulness.test.ts | 12 +++ tests/ghidraClient.test.ts | 35 ++++++- tests/ghidraLauncher.test.ts | 3 +- tests/ghidraTransport.test.ts | 75 ++++++++++++++ 13 files changed, 404 insertions(+), 42 deletions(-) create mode 100644 src/ghidra/GhidraTransport.ts create mode 100644 tests/ghidraTransport.test.ts diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 0d2bed04a..57b65dac4 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -7,15 +7,20 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.Reader; import java.math.BigInteger; +import java.net.InetAddress; +import java.net.InetSocketAddress; import java.net.StandardProtocolFamily; import java.net.UnixDomainSocketAddress; import java.nio.channels.Channels; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; import java.nio.file.attribute.PosixFilePermissions; import java.security.MessageDigest; import java.util.ArrayList; @@ -85,7 +90,8 @@ public final class ReaGhidraBridge extends HeadlessScript { private static final Gson GSON = new GsonBuilder().serializeNulls().create(); private static final Set DESCRIPTOR_KEYS = Set.of( "schema_version", - "socket_path", + "transport", + "endpoint_path", "token", "run_id", "target_sha256", @@ -167,22 +173,77 @@ public final class ReaGhidraBridge extends HeadlessScript { } private void serve(SessionDescriptor descriptor) throws Exception { - Path socketPath = Path.of(descriptor.socketPath); + if (descriptor.transport.equals("unix-socket")) { + serveUnixSocket(descriptor); + return; + } + if (descriptor.transport.equals("authenticated-loopback-tcp")) { + serveLoopbackTcp(descriptor); + return; + } + throw new IllegalArgumentException("REA bridge transport is invalid"); + } + + private void serveUnixSocket(SessionDescriptor descriptor) throws Exception { + Path socketPath = Path.of(descriptor.endpointPath); Files.deleteIfExists(socketPath); try (ServerSocketChannel server = ServerSocketChannel.open(StandardProtocolFamily.UNIX)) { server.bind(UnixDomainSocketAddress.of(socketPath)); Files.setPosixFilePermissions(socketPath, PosixFilePermissions.fromString("rw-------")); - try (SocketChannel client = server.accept(); - BufferedReader reader = new BufferedReader(Channels.newReader(client, StandardCharsets.UTF_8)); - BufferedWriter writer = new BufferedWriter(Channels.newWriter(client, StandardCharsets.UTF_8))) { - serveClient(descriptor, reader, writer); - } + acceptClient(server, descriptor); } finally { Files.deleteIfExists(socketPath); } } + private void serveLoopbackTcp(SessionDescriptor descriptor) throws Exception { + Path endpointPath = Path.of(descriptor.endpointPath); + Path pendingPath = endpointPath.resolveSibling(endpointPath.getFileName() + ".pending"); + Files.deleteIfExists(endpointPath); + Files.deleteIfExists(pendingPath); + try (ServerSocketChannel server = ServerSocketChannel.open()) { + server.bind(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), 0)); + InetSocketAddress address = (InetSocketAddress) server.getLocalAddress(); + JsonObject endpoint = new JsonObject(); + endpoint.addProperty("schema_version", 1); + endpoint.addProperty("host", "127.0.0.1"); + endpoint.addProperty("port", address.getPort()); + Files.writeString( + pendingPath, + GSON.toJson(endpoint) + "\n", + StandardCharsets.UTF_8, + StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE + ); + try { + Files.move(pendingPath, endpointPath, StandardCopyOption.ATOMIC_MOVE); + } + catch (AtomicMoveNotSupportedException exception) { + Files.move(pendingPath, endpointPath); + } + acceptClient(server, descriptor); + } + finally { + Files.deleteIfExists(pendingPath); + Files.deleteIfExists(endpointPath); + } + } + + private void acceptClient( + ServerSocketChannel server, + SessionDescriptor descriptor) throws Exception { + try (SocketChannel client = server.accept(); + BufferedReader reader = new BufferedReader( + Channels.newReader(client, StandardCharsets.UTF_8) + ); + BufferedWriter writer = new BufferedWriter( + Channels.newWriter(client, StandardCharsets.UTF_8) + )) { + serveClient(descriptor, reader, writer); + } + } + private void serveClient( SessionDescriptor descriptor, BufferedReader reader, @@ -1598,11 +1659,12 @@ public final class ReaGhidraBridge extends HeadlessScript { JsonParser.parseString(Files.readString(path, StandardCharsets.UTF_8)), DESCRIPTOR_KEYS ); - if (requireInteger(object, "schema_version") != 1) { + if (requireInteger(object, "schema_version") != 2) { throw new IllegalArgumentException("REA session descriptor version is invalid"); } return new SessionDescriptor( - requireString(object, "socket_path"), + requireString(object, "transport"), + requireString(object, "endpoint_path"), requireString(object, "token"), requireString(object, "run_id"), requireSha256(object, "target_sha256"), @@ -1816,7 +1878,8 @@ public final class ReaGhidraBridge extends HeadlessScript { private record InstructionScan(List instructions, boolean truncated) {} private record TruncatedValue(String value, boolean truncated) {} private record SessionDescriptor( - String socketPath, + String transport, + String endpointPath, String token, String runId, String targetSha256, diff --git a/src/ghidra/GhidraClient.ts b/src/ghidra/GhidraClient.ts index 83f186ea0..e0c5ad5d4 100644 --- a/src/ghidra/GhidraClient.ts +++ b/src/ghidra/GhidraClient.ts @@ -1,6 +1,6 @@ import { randomBytes, randomUUID } from "node:crypto"; -import { access } from "node:fs/promises"; import type { Socket } from "node:net"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -46,9 +46,13 @@ import { attachGhidraSocket, connectGhidraSocketOnce, } from "./GhidraSocketConnection.js"; +import { + observeGhidraEndpoint, + type GhidraEndpoint, +} from "./GhidraTransport.js"; const SHUTDOWN_TIMEOUT_MS = 10_000; -const SESSION_ROOT = "/tmp"; +const SESSION_ROOT = tmpdir(); export type { GhidraClientOptions, @@ -65,7 +69,10 @@ export type GhidraOperation = /** Owns one authenticated, private, read-only Ghidra headless session. */ export class GhidraClient { readonly #options: Required< - Pick + Pick< + GhidraClientOptions, + "requestTimeoutMs" | "startupTimeoutMs" | "transport" + > > & GhidraClientOptions; readonly #pending = new PendingOperations< @@ -120,6 +127,7 @@ export class GhidraClient { ...options, requestTimeoutMs: options.requestTimeoutMs ?? GHIDRA_REQUEST_TIMEOUT_MS, startupTimeoutMs: options.startupTimeoutMs ?? GHIDRA_STARTUP_TIMEOUT_MS, + transport: options.transport ?? "unix-socket", }; this.#requestQueue = new GhidraRequestQueue( GHIDRA_MAX_QUEUED_REQUESTS, @@ -233,7 +241,15 @@ export class GhidraClient { ); } if (deadline.signal.aborted) return this.#startupInterrupted(deadline); - const socketPath = join(this.#runtimeRoot.path, "bridge.sock"); + const endpoint: GhidraEndpoint = { + transport: this.#options.transport, + path: join( + this.#runtimeRoot.path, + this.#options.transport === "unix-socket" + ? "bridge.sock" + : "bridge-endpoint.json", + ), + }; try { const snapshot = await createGhidraTargetSnapshot( this.#options.targetPath, @@ -257,7 +273,8 @@ export class GhidraClient { .launch( { runtimeRoot: this.#runtimeRoot.path, - socketPath, + transport: endpoint.transport, + endpointPath: endpoint.path, token: this.#token, runId: this.#runId, targetPath: this.#snapshotPath, @@ -283,7 +300,7 @@ export class GhidraClient { this.#process = new ProviderProcessSupervisor(launched.value, { onDiagnostic: (event) => this.#onProcessDiagnostic(event), }); - const connected = await this.#connect(socketPath, deadline); + const connected = await this.#connect(endpoint, deadline); if (!connected.ok) { const failure = connected.error; await this.#cleanup(); @@ -319,7 +336,7 @@ export class GhidraClient { } async #connect( - socketPath: string, + endpoint: GhidraEndpoint, deadline: ProviderStartupDeadline, ): Promise> { while (deadline.remainingMs() > 0) { @@ -331,15 +348,15 @@ export class GhidraClient { return err( this.#failure("process", "Ghidra exited before bridge startup"), ); - try { - await access(socketPath); - } catch { + const observed = await observeGhidraEndpoint(endpoint); + if (!observed.ok) return observed; + if (observed.value === null) { if ((await deadline.wait(50)) === "aborted") return err(this.#interruptionFailure(deadline)); continue; } const connected = await connectGhidraSocketOnce( - socketPath, + observed.value, deadline.signal, ); if (connected.ok) { @@ -565,6 +582,7 @@ export class GhidraClient { return createGhidraDiagnostics({ targetPath: this.#options.targetPath, targetSha256: this.#options.targetSha256, + transport: this.#options.transport, providerVersion: this.#options.providerVersion, profileDigest: this.#options.profileDigest, ...(this.#runtimeRoot === undefined diff --git a/src/ghidra/GhidraClientTypes.ts b/src/ghidra/GhidraClientTypes.ts index c6cdda222..a0880ba2a 100644 --- a/src/ghidra/GhidraClientTypes.ts +++ b/src/ghidra/GhidraClientTypes.ts @@ -3,6 +3,7 @@ import type { Result } from "../domain/result.js"; import type { GhidraLauncher } from "./GhidraLauncher.js"; import type { GhidraSessionError } from "./GhidraSessionError.js"; import type { GhidraSessionInfo } from "./GhidraSessionValues.js"; +import type { GhidraTransportKind } from "./GhidraTransport.js"; /** Result of opening and authenticating one headless Ghidra session. */ export type GhidraStartResult = Result; @@ -34,6 +35,7 @@ export interface GhidraClientOptions { readonly launcher: GhidraLauncher; readonly targetPath: string; readonly targetSha256: string; + readonly transport?: GhidraTransportKind; readonly providerVersion: string; readonly profileDigest: string; readonly requestTimeoutMs?: number; diff --git a/src/ghidra/GhidraDiagnostics.ts b/src/ghidra/GhidraDiagnostics.ts index 147f49c0f..7fe06f684 100644 --- a/src/ghidra/GhidraDiagnostics.ts +++ b/src/ghidra/GhidraDiagnostics.ts @@ -1,11 +1,13 @@ import type { JsonValue } from "../domain/jsonValue.js"; import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js"; import type { GhidraLaunch } from "./GhidraLauncher.js"; +import type { GhidraTransportKind } from "./GhidraTransport.js"; /** Inputs needed to project bounded diagnostics for one Ghidra runtime. */ export interface GhidraDiagnosticsOptions { readonly targetPath: string; readonly targetSha256: string; + readonly transport: GhidraTransportKind; readonly providerVersion: string; readonly profileDigest: string; readonly runtimeRoot?: string; @@ -32,13 +34,18 @@ export const createGhidraDiagnostics = ( return { target_path: options.targetPath, target_sha256: options.targetSha256, + transport: options.transport, provider_version: options.providerVersion, profile_digest: options.profileDigest, ...(options.runtimeRoot === undefined ? {} : { runtime_root: options.runtimeRoot, - socket_path: `${options.runtimeRoot}/bridge.sock`, + endpoint_path: `${options.runtimeRoot}/${ + options.transport === "unix-socket" + ? "bridge.sock" + : "bridge-endpoint.json" + }`, }), ...(options.launch === undefined ? {} diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index c2316da1a..ee7929d08 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -17,11 +17,13 @@ import { GHIDRA_MAX_HEAP, } from "./GhidraDefaults.js"; import { ghidraJavaEnvironment } from "./GhidraInstallation.js"; +import type { GhidraTransportKind } from "./GhidraTransport.js"; /** Private paths and identity material for one headless Ghidra import. */ export interface GhidraLaunchSession { readonly runtimeRoot: string; - readonly socketPath: string; + readonly transport: GhidraTransportKind; + readonly endpointPath: string; readonly token: string; readonly runId: string; readonly targetPath: string; @@ -86,8 +88,9 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { await writeFileAtomic( paths.descriptorPath, `${JSON.stringify({ - schema_version: 1, - socket_path: session.socketPath, + schema_version: 2, + transport: session.transport, + endpoint_path: session.endpointPath, token: session.token, run_id: session.runId, target_sha256: session.targetSha256, diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index d20cb61cf..527f633aa 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -357,6 +357,10 @@ export class GhidraProvider implements AnalysisProviderCandidate { }), targetPath: target.path, targetSha256: target.sha256, + transport: + installation.platform === "win32" + ? "authenticated-loopback-tcp" + : "unix-socket", providerVersion: prerequisites.value.providerVersion, profileDigest: committedProfile.digest, logger: this.logger.child({ layer: "ghidra-bridge" }), diff --git a/src/ghidra/GhidraSocketConnection.ts b/src/ghidra/GhidraSocketConnection.ts index 28b9fa01b..c7d144bb9 100644 --- a/src/ghidra/GhidraSocketConnection.ts +++ b/src/ghidra/GhidraSocketConnection.ts @@ -2,6 +2,7 @@ import { createConnection, type Socket } from "node:net"; import { err, ok, type Result } from "../domain/result.js"; import { GhidraSessionError } from "./GhidraSessionError.js"; +import type { GhidraConnectTarget } from "./GhidraTransport.js"; /** Stable listeners attached for the lifetime of one bridge socket. */ export interface GhidraSocketHandlers { @@ -26,13 +27,16 @@ export const attachGhidraSocket = ( }; }; -/** Open one abort-aware Ghidra Unix-socket connection attempt. */ +/** Open one abort-aware connection to an observed local Ghidra endpoint. */ export const connectGhidraSocketOnce = ( - socketPath: string, + target: GhidraConnectTarget, signal: AbortSignal, ): Promise> => new Promise((resolve) => { - const socket = createConnection(socketPath); + const socket = + "path" in target + ? createConnection(target.path) + : createConnection({ host: target.host, port: target.port }); const detach = (): void => { socket.off("connect", onConnect); socket.off("error", onError); @@ -48,7 +52,7 @@ export const connectGhidraSocketOnce = ( resolve( err( new GhidraSessionError("start", "Ghidra bridge connection failed", { - socket_path: socketPath, + endpoint: target, error: cause.message, }), ), @@ -62,7 +66,7 @@ export const connectGhidraSocketOnce = ( new GhidraSessionError( "cancelled", "Ghidra bridge connection was cancelled", - { socket_path: socketPath }, + { endpoint: target }, ), ), ); diff --git a/src/ghidra/GhidraTransport.ts b/src/ghidra/GhidraTransport.ts new file mode 100644 index 000000000..65ef1641a --- /dev/null +++ b/src/ghidra/GhidraTransport.ts @@ -0,0 +1,129 @@ +import { access, open } from "node:fs/promises"; + +import { err, ok, type Result } from "../domain/result.js"; +import { GhidraSessionError } from "./GhidraSessionError.js"; + +/** Local bridge transports admitted by the versioned session descriptor. */ +export type GhidraTransportKind = "unix-socket" | "authenticated-loopback-tcp"; + +/** Private runtime coordinate used to discover one bridge listener. */ +export interface GhidraEndpoint { + readonly transport: GhidraTransportKind; + readonly path: string; +} + +/** Exact Node connection target after endpoint discovery. */ +export type GhidraConnectTarget = + | { readonly path: string } + | { readonly host: "127.0.0.1"; readonly port: number }; + +const MAX_ENDPOINT_BYTES = 1024; + +/** Observe a ready endpoint, returning null while its bridge is not listening. */ +export const observeGhidraEndpoint = async ( + endpoint: GhidraEndpoint, +): Promise> => { + if (endpoint.transport === "unix-socket") { + try { + await access(endpoint.path); + return ok({ path: endpoint.path }); + } catch (cause: unknown) { + return isMissing(cause) + ? ok(null) + : err( + endpointFailure( + endpoint, + "Ghidra socket observation failed", + cause, + ), + ); + } + } + let handle; + try { + handle = await open(endpoint.path, "r"); + } catch (cause: unknown) { + return isMissing(cause) + ? ok(null) + : err( + endpointFailure( + endpoint, + "Ghidra endpoint observation failed", + cause, + ), + ); + } + try { + const bytes = Buffer.alloc(MAX_ENDPOINT_BYTES + 1); + const observed = await handle.read(bytes, 0, bytes.length, 0); + if (observed.bytesRead === 0 || observed.bytesRead > MAX_ENDPOINT_BYTES) + return err(endpointFailure(endpoint, "Ghidra TCP endpoint is invalid")); + return parseTcpEndpoint( + endpoint, + bytes.subarray(0, observed.bytesRead).toString("utf8"), + ); + } catch (cause: unknown) { + return err(endpointFailure(endpoint, "Ghidra endpoint read failed", cause)); + } finally { + await handle.close(); + } +}; + +const parseTcpEndpoint = ( + endpoint: GhidraEndpoint, + encoded: string, +): Result => { + try { + const value: unknown = JSON.parse(encoded); + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) || + !hasExactKeys(value, ["host", "port", "schema_version"]) || + value.schema_version !== 1 || + value.host !== "127.0.0.1" || + !Number.isSafeInteger(value.port) || + typeof value.port !== "number" || + value.port < 1 || + value.port > 65_535 + ) + return err(endpointFailure(endpoint, "Ghidra TCP endpoint is invalid")); + return ok({ host: "127.0.0.1", port: value.port }); + } catch (cause: unknown) { + return err( + endpointFailure(endpoint, "Ghidra TCP endpoint is invalid", cause), + ); + } +}; + +const hasExactKeys = ( + value: object, + expected: readonly string[], +): value is Record => { + const keys = Object.keys(value).sort((left, right) => + left.localeCompare(right), + ); + return ( + keys.length === expected.length && + keys.every((key, index) => key === expected[index]) + ); +}; + +const endpointFailure = ( + endpoint: GhidraEndpoint, + message: string, + cause?: unknown, +): GhidraSessionError => + new GhidraSessionError( + "start", + message, + { + transport: endpoint.transport, + endpoint_path: endpoint.path, + ...(cause instanceof Error ? { error: cause.message } : {}), + }, + cause instanceof Error ? { cause } : undefined, + ); + +const isMissing = (cause: unknown): boolean => + cause instanceof Error && "code" in cause && cause.code === "ENOENT"; diff --git a/tests/fixtures/fakeGhidra.mjs b/tests/fixtures/fakeGhidra.mjs index d16937c7f..f26f4682c 100644 --- a/tests/fixtures/fakeGhidra.mjs +++ b/tests/fixtures/fakeGhidra.mjs @@ -1,23 +1,25 @@ -import { rm } from "node:fs/promises"; +import { rename, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:net"; const [ - socketPath, + endpointPath, token, runId, providerVersion, profileDigest, targetSha256, + transport, mode = "success", ] = process.argv.slice(2); if ( - socketPath === undefined || + endpointPath === undefined || token === undefined || runId === undefined || providerVersion === undefined || profileDigest === undefined || - targetSha256 === undefined + targetSha256 === undefined || + !["unix-socket", "authenticated-loopback-tcp"].includes(transport) ) { process.exit(64); } @@ -25,7 +27,7 @@ if ( if (mode === "exit") process.exit(73); if (mode === "silent") setInterval(() => undefined, 1_000); else { - await rm(socketPath, { force: true }); + await rm(endpointPath, { force: true }); const server = createServer((socket) => { socket.setEncoding("utf8"); let buffer = ""; @@ -128,7 +130,24 @@ else { } }); }); - server.listen(socketPath); + if (transport === "unix-socket") server.listen(endpointPath); + else { + server.listen(0, "127.0.0.1", async () => { + const address = server.address(); + if (address === null || typeof address === "string") process.exit(65); + const pending = `${endpointPath}.pending`; + await writeFile( + pending, + `${JSON.stringify({ + schema_version: 1, + host: "127.0.0.1", + port: address.port, + })}\n`, + { flag: "wx" }, + ); + await rename(pending, endpointPath); + }); + } } const sessionInfo = ({ diff --git a/tests/ghidraBridgeTruthfulness.test.ts b/tests/ghidraBridgeTruthfulness.test.ts index fa13313d4..d435b3422 100644 --- a/tests/ghidraBridgeTruthfulness.test.ts +++ b/tests/ghidraBridgeTruthfulness.test.ts @@ -79,6 +79,18 @@ describe("Ghidra bridge truthfulness", () => { ); }); + it("binds the Windows fallback to authenticated IPv4 loopback", () => { + expect(bridgeSource).toContain( + 'descriptor.transport.equals("unix-socket")', + ); + expect(bridgeSource).toContain( + 'descriptor.transport.equals("authenticated-loopback-tcp")', + ); + expect(bridgeSource).toContain('InetAddress.getByName("127.0.0.1")'); + expect(bridgeSource).toContain('endpoint.addProperty("host", "127.0.0.1")'); + expect(bridgeSource).toContain("StandardCopyOption.ATOMIC_MOVE"); + }); + it("does not admit mutation methods into the Java dispatch", () => { for (const method of [ 'case "set_comment"', diff --git a/tests/ghidraClient.test.ts b/tests/ghidraClient.test.ts index 249d7fcd3..5cdaab61a 100644 --- a/tests/ghidraClient.test.ts +++ b/tests/ghidraClient.test.ts @@ -17,6 +17,7 @@ import type { GhidraLaunchSession, GhidraLauncher, } from "../src/ghidra/GhidraLauncher.js"; +import type { GhidraTransportKind } from "../src/ghidra/GhidraTransport.js"; import { GHIDRA_SESSION_CAPABILITIES } from "../src/ghidra/GhidraSessionValues.js"; const fixturePath = fileURLToPath( @@ -46,7 +47,7 @@ type FixtureMode = class FixtureLauncher implements GhidraLauncher { readonly runtimeRoots: string[] = []; - readonly socketPaths: string[] = []; + readonly endpointPaths: string[] = []; readonly tokens: string[] = []; readonly processes: ChildProcess[] = []; @@ -54,7 +55,7 @@ class FixtureLauncher implements GhidraLauncher { async launch(session: GhidraLaunchSession) { this.runtimeRoots.push(session.runtimeRoot); - this.socketPaths.push(session.socketPath); + this.endpointPaths.push(session.endpointPath); this.tokens.push(session.token); const projectRoot = join(session.runtimeRoot, "project"); await mkdir(projectRoot, { recursive: true }); @@ -62,12 +63,13 @@ class FixtureLauncher implements GhidraLauncher { process.execPath, [ fixturePath, - session.socketPath, + session.endpointPath, session.token, session.runId, session.providerVersion, session.profileDigest, session.targetSha256, + session.transport, this.mode, ], { stdio: ["ignore", "pipe", "pipe"] }, @@ -90,12 +92,16 @@ const clientFor = ( readonly startupTimeoutMs?: number; readonly requestTimeoutMs?: number; readonly onDiagnostic?: (event: GhidraDiagnostic) => void; + readonly transport?: GhidraTransportKind; } = {}, ): GhidraClient => { const client = new GhidraClient({ launcher, targetPath: fixturePath, targetSha256: TARGET_SHA256, + ...(options.transport === undefined + ? {} + : { transport: options.transport }), providerVersion: PROVIDER_VERSION, profileDigest: PROFILE_DIGEST, startupTimeoutMs: options.startupTimeoutMs ?? 1_000, @@ -132,7 +138,25 @@ describe("GhidraClient", () => { }, }, }); - expect(Buffer.byteLength(launcher.socketPaths[0] ?? "")).toBeLessThan(108); + expect(Buffer.byteLength(launcher.endpointPaths[0] ?? "")).toBeLessThan( + 108, + ); + }); + + it("completes the same authenticated handshake over loopback TCP", async () => { + const launcher = new FixtureLauncher(); + const client = clientFor(launcher, { + transport: "authenticated-loopback-tcp", + }); + + await expect(client.start()).resolves.toMatchObject({ + ok: true, + value: { + bridge_version: 4, + target: { sha256: TARGET_SHA256 }, + }, + }); + expect(launcher.endpointPaths[0]).toMatch(/bridge-endpoint\.json$/u); }); it("correlates an admitted inventory request after startup", async () => { @@ -369,7 +393,8 @@ describe("GhidraClient", () => { expect(client.diagnostics()).toMatchObject({ runtime_root: launcher.runtimeRoots[0], - socket_path: launcher.socketPaths[0], + transport: "unix-socket", + endpoint_path: launcher.endpointPaths[0], project_root: join(launcher.runtimeRoots[0] ?? "", "project"), process_id: expect.any(Number), }); diff --git a/tests/ghidraLauncher.test.ts b/tests/ghidraLauncher.test.ts index 7cf2e1039..18285fb73 100644 --- a/tests/ghidraLauncher.test.ts +++ b/tests/ghidraLauncher.test.ts @@ -72,7 +72,8 @@ describe("Ghidra headless launcher", () => { }); const launched = await launcher.launch({ runtimeRoot, - socketPath: join(runtimeRoot, "bridge.sock"), + transport: "unix-socket", + endpointPath: join(runtimeRoot, "bridge.sock"), token, runId: "d6fcbb66-e829-4ff6-a535-0035aec63139", targetPath: "/tmp/fixture", diff --git a/tests/ghidraTransport.test.ts b/tests/ghidraTransport.test.ts new file mode 100644 index 000000000..1100bd6fb --- /dev/null +++ b/tests/ghidraTransport.test.ts @@ -0,0 +1,75 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { observeGhidraEndpoint } from "../src/ghidra/GhidraTransport.js"; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe("Ghidra local transport", () => { + it("observes Unix readiness without interpreting endpoint content", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-ghidra-transport-")); + roots.push(root); + const path = join(root, "bridge.sock"); + + await expect( + observeGhidraEndpoint({ transport: "unix-socket", path }), + ).resolves.toEqual({ ok: true, value: null }); + await writeFile(path, "fixture"); + await expect( + observeGhidraEndpoint({ transport: "unix-socket", path }), + ).resolves.toEqual({ ok: true, value: { path } }); + }); + + it("accepts only an exact IPv4 loopback endpoint record", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-ghidra-transport-")); + roots.push(root); + const path = join(root, "bridge-endpoint.json"); + await writeFile( + path, + `${JSON.stringify({ schema_version: 1, host: "127.0.0.1", port: 49152 })}\n`, + ); + + await expect( + observeGhidraEndpoint({ + transport: "authenticated-loopback-tcp", + path, + }), + ).resolves.toEqual({ + ok: true, + value: { host: "127.0.0.1", port: 49152 }, + }); + }); + + it.each([ + { schema_version: 1, host: "0.0.0.0", port: 49152 }, + { schema_version: 1, host: "127.0.0.1", port: 0 }, + { schema_version: 1, host: "127.0.0.1", port: 49152, token: "leak" }, + ])( + "rejects an invalid or expanded TCP endpoint: $host:$port", + async (value) => { + const root = await mkdtemp(join(tmpdir(), "rea-ghidra-transport-")); + roots.push(root); + const path = join(root, "bridge-endpoint.json"); + await writeFile(path, JSON.stringify(value)); + + await expect( + observeGhidraEndpoint({ + transport: "authenticated-loopback-tcp", + path, + }), + ).resolves.toMatchObject({ + ok: false, + error: { kind: "start", message: "Ghidra TCP endpoint is invalid" }, + }); + }, + ); +});