From 3cfa00a29c1eb531faca9542935446e6d751ff4c Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Sat, 17 Oct 2026 15:17:03 +0800
Subject: [PATCH] fix(ghidra): preserve valid Unicode language metadata
Validate XML bytes before shared syntax parsing so literal U+FFFD remains valid while malformed bytes and declaration mismatches fail at their producing boundary.
Validation: 92 focused checks, 3 selected fake Apktool boundary checks, 6 final encoding controls, check:fast and docs:check. Ghidra coverage uses tiny real files and descriptors, without a real engine or Windows qualification.
Refs #4712
---
docs/installation.md | 2 +
src/ghidra/GhidraLanguageCatalog.ts | 18 +++--
.../ghidraLanguageCatalogEncoding.test.ts | 68 +++++++++++++++++++
3 files changed, 81 insertions(+), 7 deletions(-)
create mode 100644 tests/boundary/filesystem/ghidraLanguageCatalogEncoding.test.ts
diff --git a/docs/installation.md b/docs/installation.md
index 7dca3450b0..0bfd00063c 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -1024,6 +1024,8 @@ export REA_GHIDRA_COMPILER_SPEC_ID="borlandcpp" # optional; requires a languag
REA passes `-processor`/`-cspec`, checks both against the installation's `.ldefs`, and records them in the analysis profile. DOS imports reject an override.
+Language definitions must be well-formed XML encoded as UTF-8 or BOM-marked UTF-16, with any encoding declaration matching the bytes. Literal Unicode replacement characters (U+FFFD) are accepted; malformed source bytes are rejected.
+
To seed code that auto-analysis cannot reach (computed jumps, pointers in data):
```bash
diff --git a/src/ghidra/GhidraLanguageCatalog.ts b/src/ghidra/GhidraLanguageCatalog.ts
index 1cde8769f4..1cd939af20 100644
--- a/src/ghidra/GhidraLanguageCatalog.ts
+++ b/src/ghidra/GhidraLanguageCatalog.ts
@@ -1,7 +1,8 @@
import { open, readdir } from "node:fs/promises";
import type { FileHandle } from "node:fs/promises";
import { join } from "node:path";
-import { DOMParser, onWarningStopParsing } from "@xmldom/xmldom";
+import { parseXmlDocument } from "../domain/xmlDocument.js";
+import { decodeXmlPlistText } from "../domain/propertyListXmlText.js";
import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
@@ -80,9 +81,12 @@ export const parseGhidraLanguageDefinitions = (
text: string,
): readonly GhidraLanguageDefinition[] => {
const definitions: GhidraLanguageDefinition[] = [];
- const document = new DOMParser({
- onError: onWarningStopParsing,
- }).parseFromString(text, "application/xml");
+ const parsed = parseXmlDocument(text, {
+ doctype: "allow",
+ lineEndings: "normalize",
+ });
+ if (!parsed.ok) throw parsed.error;
+ const document = parsed.value;
const root = document.documentElement;
if (root?.tagName !== "language_definitions")
throw new SyntaxError("Expected Ghidra language_definitions XML root");
@@ -164,10 +168,10 @@ export const readGhidraLanguageCatalog = async (
if (!entry.endsWith(".ldefs")) continue;
const bytes = await readLanguageDefinitionFile(join(directory, entry));
if (bytes === undefined) continue;
- // XML 1.0 section 4.3.3: a leading UTF-8 BOM is an encoding signature,
- // not document content; TextDecoder consumes it, Buffer#toString does not.
+ // The XML byte owner validates encoding and declaration before syntax
+ // parsing; a literal U+FFFD cannot diagnose invalid source bytes.
definitions.push(
- ...parseGhidraLanguageDefinitions(new TextDecoder().decode(bytes)),
+ ...parseGhidraLanguageDefinitions(decodeXmlPlistText(bytes)),
);
}
}
diff --git a/tests/boundary/filesystem/ghidraLanguageCatalogEncoding.test.ts b/tests/boundary/filesystem/ghidraLanguageCatalogEncoding.test.ts
new file mode 100644
index 0000000000..647e5c2266
--- /dev/null
+++ b/tests/boundary/filesystem/ghidraLanguageCatalogEncoding.test.ts
@@ -0,0 +1,68 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { expect, it } from "vitest";
+import { readGhidraLanguageCatalog } from "../../../src/ghidra/GhidraLanguageCatalog.js";
+import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
+
+const xml =
+ '';
+it.each([
+ { name: "literal U+FFFD", bytes: Buffer.from(xml), expectedError: null },
+ {
+ name: "UTF-8 BOM",
+ bytes: Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(xml)]),
+ expectedError: null,
+ },
+ {
+ name: "UTF-16 BOM/declaration",
+ bytes: Buffer.concat([
+ Buffer.from([0xff, 0xfe]),
+ Buffer.from('' + xml, "utf16le"),
+ ]),
+ expectedError: null,
+ },
+ {
+ name: "malformed UTF-8",
+ bytes: Buffer.concat([
+ Buffer.from('',
+ ),
+ ]),
+ expectedError: "invalid-bytes",
+ },
+ {
+ name: "encoding mismatch",
+ bytes: Buffer.from('' + xml),
+ expectedError: "encoding-mismatch",
+ },
+ {
+ name: "malformed XML alongside U+FFFD",
+ bytes: Buffer.from(xml.replace('id="toy:LE:32:default"', "id=unquoted")),
+ expectedError: "malformed",
+ },
+])(
+ "reads $name at the language-file byte boundary",
+ async ({ bytes, expectedError }) => {
+ const root = await createTestTempDirectory("rea-ghidra-xml-encoding-");
+ const directory = join(
+ root,
+ "Ghidra",
+ "Processors",
+ "toy",
+ "data",
+ "languages",
+ );
+ await mkdir(directory, { recursive: true });
+ await writeFile(join(directory, "toy.ldefs"), bytes);
+ if (expectedError === null)
+ expect(await readGhidraLanguageCatalog(root)).toEqual([
+ { id: "toy:LE:32:default", compilerSpecIds: ["default"] },
+ ]);
+ else
+ await expect(readGhidraLanguageCatalog(root)).rejects.toMatchObject({
+ reason: expectedError,
+ });
+ },
+);