fix(ghidra): derive installation admission from Java property semantics

This commit is contained in:
morluto 2026-10-13 13:39:48 +08:00
parent 1f7f808c4f
commit 2187e16f14
10 changed files with 408 additions and 52 deletions

View File

@ -196,7 +196,7 @@ describe("Ghidra seed profile", () => {
const host: GhidraInstallationHost = {
platform: "linux",
architecture: "x64",
readText: () => "application.version=12.1.4\n",
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
version: "21.0.11",

View File

@ -15,7 +15,7 @@ const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
const installationHost = (
overrides: Partial<GhidraInstallationHost> = {},
): GhidraInstallationHost => ({
readText: () => "application.version=12.1.4\n",
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
version: "21.0.11",
@ -111,7 +111,7 @@ describe("Ghidra doctor integration", () => {
"wrong version",
inspection(
{ installDir: INSTALL },
{ readText: () => "application.version=11.4\n" },
{ readText: () => "application.version=11.4\napplication.java.min=21\n" },
),
"ghidra-version",
],
@ -136,6 +136,14 @@ describe("Ghidra doctor integration", () => {
),
"ghidra-java",
],
[
"missing Java minimum",
inspection(
{ installDir: INSTALL },
{ readText: () => "application.version=12.1.4\n" },
),
"ghidra-java",
],
[
"unsupported architecture",
inspection({ installDir: INSTALL, architecture: "arm" }),

View File

@ -12,6 +12,8 @@ import {
import {
SUPPORTED_GHIDRA_JAVA_MAJOR,
SUPPORTED_GHIDRA_VERSION,
ghidraApplicationProperties,
ghidraJavaRequirement,
} from "./GhidraInstallationPolicy.js";
const INSTALL = "/opt/ghidra";
@ -42,7 +44,7 @@ const host = (
): GhidraInstallationHost => ({
readText: (path) =>
path === PROPERTIES
? `application.version=${SUPPORTED_GHIDRA_VERSION}\n`
? `application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=${String(SUPPORTED_GHIDRA_JAVA_MAJOR)}\n`
: undefined,
executable: (path) =>
path === HEADLESS || path === MAC_DECOMPILER || path === LINUX_DECOMPILER,
@ -190,7 +192,7 @@ describe("Ghidra installation inspection", () => {
architecture: "x64",
readText: (path) =>
path === WINDOWS_PROPERTIES
? `application.version=${SUPPORTED_GHIDRA_VERSION}\n`
? `application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=21\n`
: undefined,
executable: (path) =>
path === WINDOWS_HEADLESS || path === WINDOWS_DECOMPILER,
@ -275,7 +277,9 @@ describe("Ghidra installation rejection diagnostics", () => {
platform: "linux" as const,
architecture: "x64" as const,
},
override: { readText: () => "application.version=11.4\n" },
override: {
readText: () => "application.version=11.4\napplication.java.min=21\n",
},
failed: "version",
code: "unsupported_version",
},
@ -350,6 +354,167 @@ describe("Ghidra installation rejection diagnostics", () => {
});
describe("Ghidra release line and JDK bounds", () => {
it("uses Java Properties last-key, delimiter, escape, and continuation rules", () => {
const properties = ghidraApplicationProperties(
[
"application.version=11.4",
"# comment ending with a slash " + "\\",
"application.\\u0076ersion : 12.\\",
" 1.\\u0034",
"! another comment " + "\\",
"application.java.\\u006din\t21",
"application.java.max=",
].join("\n"),
);
expect(properties).toEqual({
providerVersion: "12.1.4",
javaMinimum: { status: "major", major: 21 },
javaMaximum: { status: "absent" },
parseError: null,
});
expect(
ghidraApplicationProperties("application.version=12.1.4").javaMinimum,
).toEqual({
status: "absent",
});
expect(
ghidraApplicationProperties(
"application.version=12.1.4\napplication.java.min=\n",
).javaMinimum,
).toEqual({ status: "unresolved", raw: "" });
expect(
ghidraApplicationProperties("application.version=12.1.4\\")
.providerVersion,
).toBe("12.1.4");
expect(
ghidraApplicationProperties("application.version=12.\\\n #.1")
.providerVersion,
).toBe("12.#.1");
expect(
ghidraApplicationProperties(
"application.version=12.\\\n\napplication.version=12.1.4",
).providerVersion,
).toBe("12.1.4");
});
it("matches Ghidra integer Java bounds and zero maximum semantics", () => {
const properties = ghidraApplicationProperties(
"application.java.min=21.0\napplication.java.max=0\n",
);
expect(properties.javaMinimum).toEqual({
status: "unresolved",
raw: "21.0",
});
expect(properties.javaMaximum).toEqual({ status: "absent" });
expect(
ghidraApplicationProperties("application.java.max=+0").javaMaximum,
).toEqual({
status: "absent",
});
expect(
ghidraApplicationProperties("application.java.max=+0").javaMaximum,
).toEqual({ status: "absent" });
expect(
ghidraApplicationProperties("application.java.min=+21").javaMinimum,
).toEqual({
status: "major",
major: 21,
});
expect(
ghidraApplicationProperties("application.java.min=٢١").javaMinimum,
).toEqual({
status: "major",
major: 21,
});
expect(
ghidraApplicationProperties("application.java.min=21").javaMinimum,
).toEqual({
status: "major",
major: 21,
});
expect(
ghidraApplicationProperties("application.java.max=+0").javaMaximum,
).toEqual({
status: "unresolved",
raw: "+0",
});
expect(
ghidraApplicationProperties("application.java.min=②").javaMinimum,
).toEqual({
status: "unresolved",
raw: "②",
});
expect(
ghidraApplicationProperties("application.java.min=2147483648")
.javaMinimum,
).toEqual({ status: "unresolved", raw: "2147483648" });
});
it("keeps an unavailable properties file unresolved for Java admission", () => {
expect(ghidraJavaRequirement(undefined).unresolved).toBe(
"application.properties is unavailable",
);
});
it("preserves property value trailing whitespace instead of normalizing it", () => {
const properties = ghidraApplicationProperties(
"application.version=12.1.4 \napplication.java.min=21 \n",
);
expect(properties.providerVersion).toBe("12.1.4 ");
expect(properties.javaMinimum).toEqual({
status: "unresolved",
raw: "21 ",
});
expect(properties.parseError).toBeNull();
});
});
describe("Ghidra malformed property admission", () => {
it("fails installation and doctor admission closed on malformed Unicode escapes", () => {
const result = inspectGhidraInstallation(
{
environment: {},
installDir: INSTALL,
platform: "linux",
architecture: "x64",
},
host({
readText: () =>
"application.version=12.1.4\nbroken=\\u12G4\napplication.java.min=17\n",
}),
);
expect(result.status).toBe("unavailable");
expect(result.providerVersion).toBeNull();
expect(result.checks).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "version",
status: "failed",
code: "version_unresolved",
detail: expect.stringContaining("Malformed Unicode escape"),
}),
expect.objectContaining({
name: "java",
status: "failed",
code: "version_unresolved",
remediation: expect.stringContaining(
"application.properties parse failed",
),
}),
]),
);
expect(projectGhidraDoctorInspection(result)).toMatchObject({
available: false,
registrationEnvironment: {},
checks: expect.arrayContaining([
expect.objectContaining({ name: "version", ok: false }),
expect.objectContaining({ name: "java", ok: false }),
]),
});
});
});
describe("Ghidra installation version admission", () => {
it.each([
{
name: "JDK above the installation maximum",
@ -367,16 +532,23 @@ describe("Ghidra release line and JDK bounds", () => {
failed: "java",
code: "version_unresolved",
},
{
name: "missing Java minimum",
readText: () => "application.version=12.1.4\n",
probeJava: () => JAVA,
failed: "java",
code: "version_unresolved",
},
{
name: "unparseable Ghidra version",
readText: () => "application.version=latest\n",
readText: () => "application.version=latest\napplication.java.min=21\n",
probeJava: () => JAVA,
failed: "version",
code: "version_unresolved",
},
{
name: "different Ghidra line",
readText: () => "application.version=12.2.0\n",
readText: () => "application.version=12.2.0\napplication.java.min=21\n",
probeJava: () => JAVA,
failed: "version",
code: "unsupported_version",
@ -422,7 +594,7 @@ describe("Ghidra release line and JDK bounds", () => {
});
});
it("honors an installation that declares a lower Java minimum", () => {
it("accepts a lower Java minimum declared by the installation", () => {
expect(
inspectGhidraInstallation(
{

View File

@ -641,14 +641,20 @@ const versionCheck = (
properties: GhidraApplicationProperties | undefined,
): GhidraInstallationCheck => {
const raw = properties?.providerVersion;
const parseError = properties?.parseError;
return installationCheck({
name: "version",
passed: ghidraReleaseAccepted(raw),
passed: parseError == null && ghidraReleaseAccepted(raw),
code:
ghidraReleaseCompatibility(raw).status === "unsupported"
? "unsupported_version"
: "version_unresolved",
detail: ghidraVersionDetail(raw),
parseError != null
? "version_unresolved"
: ghidraReleaseCompatibility(raw).status === "unsupported"
? "unsupported_version"
: "version_unresolved",
detail:
parseError == null
? ghidraVersionDetail(raw)
: `application.properties could not be parsed: ${parseError}`,
remediation: `Install a Ghidra ${ghidraReleaseLine()} release (verified with ${SUPPORTED_GHIDRA_VERSION}), or update REA for another Ghidra line.`,
});
};
@ -701,7 +707,9 @@ const javaRemediation = (
const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
readText(path) {
try {
return readFileSync(path, "utf8");
// Ghidra loads this file with Properties.load(InputStream), which maps
// bytes as ISO-8859-1 rather than decoding UTF-8.
return readFileSync(path).toString("latin1");
} catch (cause: unknown) {
// best-effort cleanup: optional install probing; unreadable means unknown.
void cause;

View File

@ -1,10 +1,8 @@
import { err, ok, type Result } from "../domain/result.js";
/** Ghidra build whose bridge behavior this REA release verifies. */
export const SUPPORTED_GHIDRA_VERSION = "12.1.4";
/**
* JDK major used when an installation omits `application.java.min`.
* Admission uses that installation's `application.java.min` and
* `application.java.max`. An empty maximum means the release sets no upper bound.
*/
/** Verified JDK major; admission uses the installation's declared bounds. */
export const SUPPORTED_GHIDRA_JAVA_MAJOR = 21;
/** Numeric Ghidra release read from `application.version`. */
@ -40,30 +38,49 @@ type GhidraJavaBound =
| { readonly status: "unresolved"; readonly raw: string };
/** JDK majors this installation admits, after reading its own properties. */
export interface GhidraJavaRequirement {
readonly minimumMajor: number;
readonly maximumMajor: number | null;
readonly unresolved: string | null;
}
export type GhidraJavaRequirement =
| {
readonly minimumMajor: number;
readonly maximumMajor: number | null;
readonly unresolved: null;
}
| { readonly unresolved: string };
/** Version and Java bounds read from one `application.properties` file. */
export interface GhidraApplicationProperties {
readonly providerVersion: string | null;
readonly javaMinimum: GhidraJavaBound;
readonly javaMaximum: GhidraJavaBound;
/** A malformed Java properties escape invalidates the entire file. */
readonly parseError: string | null;
}
const GHIDRA_RELEASE_PATTERN = /^(\d+)\.(\d+)(?:\.(\d+))?([-+].*)?$/u;
const JAVA_MAJOR_PATTERN = /^(\d+)(?:\.\d+)*$/u;
/** Read the version and Java bounds Ghidra publishes for one installation. */
export const ghidraApplicationProperties = (
content: string,
): GhidraApplicationProperties => ({
providerVersion: propertyValue(content, "application.version"),
javaMinimum: parseJavaBound(propertyValue(content, "application.java.min")),
javaMaximum: parseJavaBound(propertyValue(content, "application.java.max")),
});
): GhidraApplicationProperties => {
const parsed = parseJavaProperties(content);
if (!parsed.ok)
return {
providerVersion: null,
javaMinimum: { status: "unresolved", raw: parsed.error },
javaMaximum: { status: "unresolved", raw: parsed.error },
parseError: parsed.error,
};
const providerVersion = parsed.value.get("application.version");
const minimum = parsed.value.get("application.java.min");
const maximum = parsed.value.get("application.java.max");
return {
providerVersion: providerVersion ?? null,
javaMinimum:
minimum === undefined ? { status: "absent" } : parseJavaBound(minimum),
// Ghidra uses an explicitly empty maximum to mean no upper bound.
javaMaximum: parseJavaMaximum(maximum),
parseError: null,
};
};
/** Classify `application.version` against the verified release line. */
export const ghidraReleaseCompatibility = (
@ -130,14 +147,21 @@ export const ghidraReleaseAccepted = (
export const ghidraJavaRequirement = (
properties: GhidraApplicationProperties | undefined,
): GhidraJavaRequirement => {
const minimum = properties?.javaMinimum ?? { status: "absent" };
const maximum = properties?.javaMaximum ?? { status: "absent" };
if (properties?.parseError !== null && properties?.parseError !== undefined)
return unresolvedJavaRequirement(
`application.properties parse failed: ${properties.parseError}`,
);
if (properties === undefined)
return unresolvedJavaRequirement("application.properties is unavailable");
const minimum = properties.javaMinimum;
const maximum = properties.javaMaximum;
if (minimum.status === "unresolved")
return unresolvedJavaRequirement(`application.java.min=${minimum.raw}`);
if (minimum.status === "absent")
return unresolvedJavaRequirement("application.java.min is missing");
if (maximum.status === "unresolved")
return unresolvedJavaRequirement(`application.java.max=${maximum.raw}`);
const minimumMajor =
minimum.status === "major" ? minimum.major : SUPPORTED_GHIDRA_JAVA_MAJOR;
const minimumMajor = minimum.major;
const maximumMajor = maximum.status === "major" ? maximum.major : null;
if (maximumMajor !== null && maximumMajor < minimumMajor)
return unresolvedJavaRequirement(
@ -187,14 +211,56 @@ const parseGhidraRelease = (raw: string): GhidraReleaseVersion | undefined => {
return patch === undefined ? undefined : { major, minor, patch };
};
const parseJavaBound = (raw: string | null): GhidraJavaBound => {
if (raw === null) return { status: "absent" };
const major = integerGroup(JAVA_MAJOR_PATTERN.exec(raw)?.[1]);
const parseJavaBound = (raw: string): GhidraJavaBound => {
const major = parseJavaInteger(raw);
return major === undefined
? { status: "unresolved", raw }
: { status: "major", major };
};
const parseJavaMaximum = (raw: string | undefined): GhidraJavaBound => {
if (raw === undefined || raw === "") return { status: "absent" };
const bound = parseJavaBound(raw);
return bound.status === "major" && bound.major === 0
? { status: "absent" }
: bound;
};
const parseJavaInteger = (raw: string): number | undefined => {
let index = 0;
let sign = 1;
if (raw[index] === "+" || raw[index] === "-") {
if (raw[index] === "-") sign = -1;
index += 1;
}
if (index === raw.length) return undefined;
const limit = sign < 0 ? 2_147_483_648 : 2_147_483_647;
let value = 0;
for (; index < raw.length; index += 1) {
const character = raw[index];
if (character === undefined) return undefined;
const digit = javaDecimalDigit(character);
if (digit === undefined) return undefined;
if (value > Math.floor((limit - digit) / 10)) return undefined;
value = value * 10 + digit;
}
return sign * value;
};
/** Match Integer.parseInt(charAt) for BMP Decimal_Number characters. */
const javaDecimalDigit = (character: string): number | undefined => {
const code = character.charCodeAt(0);
if (code >= 48 && code <= 57) return code - 48;
if (!/^\p{Decimal_Number}$/u.test(character)) return undefined;
let blockStart = code;
while (
blockStart > 0 &&
/^\p{Decimal_Number}$/u.test(String.fromCharCode(blockStart - 1))
)
blockStart -= 1;
return (code - blockStart) % 10;
};
const integerGroup = (value: string | undefined): number | undefined => {
if (value === undefined) return undefined;
const parsed = Number(value);
@ -204,17 +270,119 @@ const integerGroup = (value: string | undefined): number | undefined => {
const unresolvedJavaRequirement = (
unresolved: string,
): GhidraJavaRequirement => ({
minimumMajor: SUPPORTED_GHIDRA_JAVA_MAJOR,
maximumMajor: null,
unresolved,
});
const propertyValue = (content: string, name: string): string | null => {
for (const line of content.split(/\r?\n/u)) {
const separator = line.indexOf("=");
if (separator < 0 || line.slice(0, separator).trim() !== name) continue;
const value = line.slice(separator + 1).trim();
return value.length === 0 ? null : value;
/** Parse Java Properties.load(InputStream) syntax from its ISO-8859-1 text. */
const parseJavaProperties = (
content: string,
): Result<ReadonlyMap<string, string>, string> => {
const values = new Map<string, string>();
for (const line of logicalPropertyLines(content)) {
let start = 0;
while (isPropertyWhitespace(line[start])) start += 1;
if (start === line.length || line[start] === "#" || line[start] === "!")
continue;
let end = start;
let escaped = false;
while (end < line.length) {
const character = line[end];
if (
!escaped &&
(character === "=" ||
character === ":" ||
isPropertyWhitespace(character))
)
break;
if (character === "\\") escaped = !escaped;
else escaped = false;
end += 1;
}
let valueStart = end;
while (isPropertyWhitespace(line[valueStart])) valueStart += 1;
if (line[valueStart] === "=" || line[valueStart] === ":") valueStart += 1;
while (isPropertyWhitespace(line[valueStart])) valueStart += 1;
const key = decodePropertyEscapes(line.slice(start, end));
if (!key.ok) return err(key.error);
const value = decodePropertyEscapes(line.slice(valueStart));
if (!value.ok) return err(value.error);
values.set(key.value, value.value);
}
return null;
return ok(values);
};
const isPropertyWhitespace = (value: string | undefined): boolean =>
value === " " || value === "\t" || value === "\f";
/** Join odd-backslash continuations while stripping leading continuation space. */
function* logicalPropertyLines(content: string): Generator<string> {
let physicalStart = 0;
let logical = "";
let continuing = false;
while (physicalStart <= content.length) {
let physicalEnd = physicalStart;
while (
physicalEnd < content.length &&
content[physicalEnd] !== "\n" &&
content[physicalEnd] !== "\r"
)
physicalEnd += 1;
let line = content.slice(physicalStart, physicalEnd);
let leading = 0;
while (isPropertyWhitespace(line[leading])) leading += 1;
const comment =
!continuing && (line[leading] === "#" || line[leading] === "!");
if (continuing) line = line.slice(leading);
let continued = false;
if (!comment) {
let slashes = 0;
for (
let index = line.length - 1;
index >= 0 && line[index] === "\\";
index -= 1
)
slashes += 1;
continued = slashes % 2 === 1;
logical += continued ? line.slice(0, -1) : line;
}
if (!continued) {
yield logical;
logical = "";
}
continuing = continued;
if (physicalEnd === content.length) break;
physicalStart =
content[physicalEnd] === "\r" && content[physicalEnd + 1] === "\n"
? physicalEnd + 2
: physicalEnd + 1;
}
// Properties.load still consumes the accumulated line when EOF follows a
// continuation marker; the marker itself was already removed above.
if (continuing) yield logical;
}
const decodePropertyEscapes = (value: string): Result<string, string> => {
let result = "";
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (character !== "\\") {
result += character;
continue;
}
const escaped = value[++index];
if (escaped === undefined)
return err("Trailing escape in Java properties value");
if (escaped === "t") result += "\t";
else if (escaped === "n") result += "\n";
else if (escaped === "r") result += "\r";
else if (escaped === "f") result += "\f";
else if (escaped === "u") {
const digits = value.slice(index + 1, index + 5);
if (digits.length !== 4 || !/^[0-9a-f]{4}$/iu.test(digits))
return err("Malformed Unicode escape in Java properties file");
result += String.fromCharCode(Number.parseInt(digits, 16));
index += 4;
} else result += escaped;
}
return ok(result);
};

View File

@ -42,7 +42,7 @@ const X86_LDEFS = `<?xml version="1.0" encoding="UTF-8"?>
const host: GhidraInstallationHost = {
platform: "linux",
architecture: "x64",
readText: () => "application.version=12.1.4\n",
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
version: "21.0.11",

View File

@ -19,7 +19,7 @@ import { ghidraProfileLanguageOverride } from "./GhidraAnalysisProfile.js";
const host: GhidraInstallationHost = {
platform: "linux",
architecture: "x64",
readText: () => "application.version=12.1.4\n",
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
version: "21.0.11",

View File

@ -38,7 +38,7 @@ const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
const installationHost = (): GhidraInstallationHost => ({
platform: "linux",
architecture: "x64",
readText: () => "application.version=12.1.4\n",
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
version: "21.0.11",

View File

@ -53,7 +53,7 @@ const RISCV_LDEFS = `<?xml version="1.0" encoding="UTF-8"?>
const host: GhidraInstallationHost = {
platform: "linux",
architecture: "x64",
readText: () => "application.version=12.1.4\n",
readText: () => "application.version=12.1.4\napplication.java.min=21\n",
executable: () => true,
probeJava: () => ({
version: "21.0.11",

View File

@ -39,7 +39,7 @@ const fixture = (version = "12.1.4") => {
{
platform: "linux",
architecture: "x64",
readText: () => `application.version=${version}\n`,
readText: () => `application.version=${version}\napplication.java.min=21\n`,
executable: () => true,
probeJava: () => ({
version: "21.0.11",