From b953f96c0aae27e1876c317a8f7db1d6f292b8ff Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:00:14 +0800 Subject: [PATCH] fix(ghidra): share Java properties semantics with PSP extension admission --- src/ghidra/GhidraInstallationPolicy.ts | 116 +----------------- src/ghidra/GhidraJavaProperties.ts | 115 +++++++++++++++++ src/ghidra/GhidraPspExtension.ts | 23 ++-- .../filesystem/ghidraPspExtension.test.ts | 37 +++++- 4 files changed, 161 insertions(+), 130 deletions(-) create mode 100644 src/ghidra/GhidraJavaProperties.ts diff --git a/src/ghidra/GhidraInstallationPolicy.ts b/src/ghidra/GhidraInstallationPolicy.ts index ead513cd8..8a35c66c3 100644 --- a/src/ghidra/GhidraInstallationPolicy.ts +++ b/src/ghidra/GhidraInstallationPolicy.ts @@ -1,4 +1,4 @@ -import { err, ok, type Result } from "../domain/result.js"; +import { parseGhidraJavaProperties } from "./GhidraJavaProperties.js"; /** Ghidra build whose bridge behavior this REA release verifies. */ export const SUPPORTED_GHIDRA_VERSION = "12.1.4"; @@ -61,7 +61,7 @@ const GHIDRA_RELEASE_PATTERN = /^(\d+)\.(\d+)(?:\.(\d+))?([-+].*)?$/u; export const ghidraApplicationProperties = ( content: string, ): GhidraApplicationProperties => { - const parsed = parseJavaProperties(content); + const parsed = parseGhidraJavaProperties(content); if (!parsed.ok) return { providerVersion: null, @@ -274,115 +274,3 @@ const unresolvedJavaRequirement = ( }); /** Parse Java Properties.load(InputStream) syntax from its ISO-8859-1 text. */ -const parseJavaProperties = ( - content: string, -): Result, string> => { - const values = new Map(); - 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 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 { - 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 => { - 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); -}; diff --git a/src/ghidra/GhidraJavaProperties.ts b/src/ghidra/GhidraJavaProperties.ts new file mode 100644 index 000000000..ab3b7bead --- /dev/null +++ b/src/ghidra/GhidraJavaProperties.ts @@ -0,0 +1,115 @@ +import { err, ok, type Result } from "../domain/result.js"; + +/** Parse Java Properties.load(InputStream) syntax from its ISO-8859-1 text. */ +export const parseGhidraJavaProperties = ( + content: string, +): Result, string> => { + const values = new Map(); + 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 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 { + 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 => { + 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); +}; diff --git a/src/ghidra/GhidraPspExtension.ts b/src/ghidra/GhidraPspExtension.ts index 1a3769378..9c017f367 100644 --- a/src/ghidra/GhidraPspExtension.ts +++ b/src/ghidra/GhidraPspExtension.ts @@ -13,6 +13,7 @@ import { import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { err, ok, type Result } from "../domain/result.js"; import type { GhidraInstallationInspection } from "./GhidraInstallation.js"; +import { parseGhidraJavaProperties } from "./GhidraJavaProperties.js"; /** Installed processor code and data, distinct from REA's analysis-extension ABI. */ export const ghidraPspExtensionSchema = z.strictObject({ @@ -88,7 +89,7 @@ export const inspectGhidraPspExtension = async ( throw new Error("The installed Allegrex extension has no loader JAR."); const hash = createHash("sha256"); let bytesRead = 0; - let properties = ""; + let properties = Buffer.alloc(0); for (const relative of files) { signal?.throwIfAborted(); const path = join(root, relative); @@ -135,22 +136,22 @@ export const inspectGhidraPspExtension = async ( ); bytesRead += before.size; if (relative === "extension.properties") - properties = Buffer.concat(chunks).toString("utf8"); + properties = Buffer.concat(chunks); } finally { await handle.close(); } } // Ghidra's extension.properties version is the compatible Ghidra version, // not the extension release (e.g. v21.4); content identity supplies the latter. - const property = (name: string): string => { - const values = properties.split(/\r?\n/u).flatMap((line) => { - const match = /^\s*([A-Za-z][A-Za-z0-9]*)\s*=\s*(.*?)\s*$/u.exec(line); - return match?.[1] === name ? [match[2] ?? ""] : []; - }); - if (values.length !== 1) - throw new Error(`Allegrex extension must declare exactly one ${name}.`); - return values[0] ?? ""; - }; + const parsedProperties = parseGhidraJavaProperties( + properties.toString("latin1"), + ); + if (!parsedProperties.ok) + throw new Error( + `Invalid Allegrex extension properties: ${parsedProperties.error}`, + ); + const property = (name: string): string | undefined => + parsedProperties.value.get(name); if ( property("name") !== "ghidra-allegrex" || property("version") !== ghidraVersion diff --git a/tests/boundary/filesystem/ghidraPspExtension.test.ts b/tests/boundary/filesystem/ghidraPspExtension.test.ts index 0e07ad629..0b90bd194 100644 --- a/tests/boundary/filesystem/ghidraPspExtension.test.ts +++ b/tests/boundary/filesystem/ghidraPspExtension.test.ts @@ -188,15 +188,42 @@ describe.skipIf(process.platform === "win32")( expect(missing.error.message).toContain("PSP_EXTENSION_UNAVAILABLE"); }); + it.each([ + { + form: "escaped key and value with colon separator", + properties: "n\\u0061me : ghidra-allegrex\nver\\u0073ion=12.1.3\n", + }, + { + form: "continued key and value lines", + properties: "name=ghidra-\\\n allegrex\nversion=12.1.\\\n 3\n", + }, + { + form: "duplicate keys use the last value", + properties: + "name=wrong\nname=ghidra-allegrex\nversion=12.1.4\nversion=12.1.3\n", + }, + ])( + "accepts Java Properties $form and hashes raw bytes", + async ({ properties }) => { + const { root } = await installation(); + const canonical = await inspectGhidraPspExtension(root, "12.1.3"); + const latin1Properties = Buffer.from( + `${properties}author=caf\u00e9\n`, + "latin1", + ); + await writeFile(join(root, "extension.properties"), latin1Properties); + const extension = await inspectGhidraPspExtension(root, "12.1.3"); + expect(extension.id).toBe("ghidra-allegrex"); + expect(extension.sha256).not.toBe(canonical.sha256); + }, + ); + it.each([ ["name=other\nversion=12.1.3\n", "does not match"], ["name=ghidra-allegrex\nversion=12.1.4\n", "does not match"], - [ - "name=ghidra-allegrex\nversion=12.1.3\nversion=12.1.3\n", - "exactly one version", - ], + ["name=ghidra-allegrex\nversion=12.1.\\uZZZZ\n", "Unicode escape"], ])( - "rejects incompatible or ambiguous extension metadata %s", + "rejects mismatched or malformed extension metadata %s", async (properties, reason) => { const { root } = await installation(); await writeFile(join(root, "extension.properties"), properties);