fix(ghidra): share Java properties semantics with PSP extension admission
This commit is contained in:
parent
fe8d0bdd36
commit
b953f96c0a
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue