fix(ghidra): share Java properties semantics with PSP extension admission

This commit is contained in:
morluto 2026-10-13 15:00:14 +08:00
parent fe8d0bdd36
commit b953f96c0a
4 changed files with 161 additions and 130 deletions

View File

@ -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<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 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

@ -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<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 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

@ -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

View File

@ -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);