diff --git a/src/ghidra/GhidraClient.ts b/src/ghidra/GhidraClient.ts index 5442586c8..375a4f064 100644 --- a/src/ghidra/GhidraClient.ts +++ b/src/ghidra/GhidraClient.ts @@ -1,6 +1,5 @@ import { randomBytes, randomUUID } from "node:crypto"; import type { Socket } from "node:net"; -import { tmpdir } from "node:os"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -47,9 +46,10 @@ import { } from "./GhidraTransport.js"; import { GhidraWire } from "./GhidraClientWire.js"; import { completeGhidraStartupHandshake } from "./GhidraClientStartup.js"; +import { ghidraSessionRoot } from "./GhidraSessionRoot.js"; const SHUTDOWN_TIMEOUT_MS = 10_000; -const SESSION_ROOT = tmpdir(); +const SESSION_ROOT = ghidraSessionRoot(); export type { GhidraClientOptions, diff --git a/src/ghidra/GhidraSessionRoot.test.ts b/src/ghidra/GhidraSessionRoot.test.ts new file mode 100644 index 000000000..bc5369dd3 --- /dev/null +++ b/src/ghidra/GhidraSessionRoot.test.ts @@ -0,0 +1,61 @@ +import { join, sep } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { ghidraSessionRoot } from "./GhidraSessionRoot.js"; + +const hasDotPrefixElement = (directory: string): boolean => + directory.split(sep).some((element) => element.startsWith(".")); + +const POSIX = process.platform !== "win32"; +const posixFallback = join(sep, "tmp"); +const posixBase = (...segments: string[]) => join(sep, ...segments); + +describe("ghidraSessionRoot", () => { + it("keeps an inherited base that Ghidra accepts", () => { + const base = posixBase("var", "tmp", "rea-run"); + expect(ghidraSessionRoot({ base, fallback: posixFallback })).toBe(base); + }); + + it("keeps a base whose elements only contain an interior dot", () => { + const base = posixBase("opt", "rea.run", "session"); + expect(ghidraSessionRoot({ base, fallback: posixFallback })).toBe(base); + }); + + it("relocates a dot-prefixed base to the supplied dot-free fallback", () => { + const base = posixBase("home", "operator", ".cache", "scratch"); + expect(ghidraSessionRoot({ base, fallback: posixFallback })).toBe( + posixFallback, + ); + }); + + it("relocates a base that is itself a hidden directory", () => { + const base = posixBase("home", "operator", ".tmp"); + expect(ghidraSessionRoot({ base, fallback: posixBase("var", "tmp") })).toBe( + posixBase("var", "tmp"), + ); + }); + + it("ignores a fallback that is itself dotted", () => { + const base = posixBase("home", "operator", ".cache", "scratch"); + expect( + ghidraSessionRoot({ + base, + fallback: posixBase("home", "operator", ".tmp"), + }), + ).toBe(posixBase("home", "operator")); + }); + + it("never returns a path with a dot-prefixed element", () => { + const directory = ghidraSessionRoot({ + base: posixBase("home", "operator", ".cache", "scratch"), + fallback: posixBase("home", "operator", ".cache"), + }); + expect(hasDotPrefixElement(directory)).toBe(false); + }); + + it.skipIf(!POSIX)("prefers the platform temp directory by default", () => { + expect( + ghidraSessionRoot({ base: posixBase("home", "operator", ".cache") }), + ).toBe(posixBase("tmp")); + }); +}); diff --git a/src/ghidra/GhidraSessionRoot.ts b/src/ghidra/GhidraSessionRoot.ts new file mode 100644 index 000000000..82c88c0bf --- /dev/null +++ b/src/ghidra/GhidraSessionRoot.ts @@ -0,0 +1,55 @@ +import { dirname, isAbsolute, resolve, sep } from "node:path"; +import { tmpdir } from "node:os"; + +/** Session roots may not contain a dot-prefixed path element. */ +const hasDotPrefixElement = (directory: string): boolean => + resolve(directory) + .split(sep) + .some((element) => element.startsWith(".")); + +/** Nearest ancestor of `directory`, inclusive, that has no dot-prefixed element. */ +const nearestSafeAncestor = (directory: string): string | undefined => { + let current = resolve(directory); + for (;;) { + if (!hasDotPrefixElement(current)) return current; + const parent = dirname(current); + if (parent === current) return undefined; + current = parent; + } +}; + +/** Coordinates accepted by {@link ghidraSessionRoot}. */ +export interface GhidraSessionRootOptions { + /** Inherited base directory, normally `os.tmpdir()`. */ + readonly base?: string; + /** Platform-specific dot-free fallback directory passed to `mkdtemp`. */ + readonly fallback?: string; + /** Platform override used by deterministic boundary tests. */ + readonly platform?: NodeJS.Platform; +} + +/** + * Select a session root usable as the parent of a Ghidra project path. + * + * Ghidra validates every project path element with `NamingUtilities.checkName` + * and aborts the headless analyzer on any element that starts with `.`, so an + * inherited `TMPDIR` such as `~/.cache/` fails every Ghidra operation + * with a generic analysis failure. Prefer the inherited base when it is usable, + * then a dot-free platform temp directory, then the nearest dot-free ancestor. + */ +export const ghidraSessionRoot = ( + options: GhidraSessionRootOptions = {}, +): string => { + const base = resolve(options.base ?? tmpdir()); + if (!hasDotPrefixElement(base)) return base; + const platform = options.platform ?? process.platform; + const fallback = + options.fallback ?? (platform === "win32" ? undefined : "/tmp"); + if ( + fallback !== undefined && + isAbsolute(fallback) && + !hasDotPrefixElement(fallback) + ) + return resolve(fallback); + return nearestSafeAncestor(base) ?? base; +};