fix(apktool): distinguish document failures from entry omissions

Return tagged string-document parsing failures instead of fabricating one unparsed entry. Preserve XML failure reasons and publish unknown counts with partial coverage; count only examined unsupported entries in validated documents. Migrate every consumer, remove the superseded throwing XML wrapper, and retain completed failure Evidence through cancellation and failed cleanup.

Adapt the document-versus-entry distinction from PR #4804 using the shared Result and XML owner. Multi-table projection and its aggregate bounds remain under review in #4777.

Validation before incoming main rebase: 229 focused tests in 15 files, four baseline controls fail, static/docs checks and two source reviews. Twelve tiny actual MCP workflows and four CLI controls; authored launchers only, no real Apktool/JVM/Windows qualification.

Fixes #4820

Co-authored-by: Abhay Tiwari <abhaytiwari0821@gmail.com>
This commit is contained in:
morlutoandAbhay Tiwari committed 2026-10-17 23:27:29 +08:00
1 parent bec88b9d77
commit dc2e8117b6
5 files changed
+251 -96

No files matched your search

+49 -29
View File
@@ -1,5 +1,6 @@
import type { Document, Element, Node } from "@xmldom/xmldom";
import type { Element, Node } from "@xmldom/xmldom";
import { parseXmlDocument } from "../domain/xmlDocument.js";
import { ok, err, type Result } from "../domain/result.js";
import {
isMap,
isScalar,
@@ -53,8 +54,16 @@ export const parseApktoolYml = (yml: string): ApktoolMetadata => {
/** Read the package attribute from the decoded Android manifest root. */
export const parseManifestPackage = (manifest: string): string | null => {
const document = parseXml(manifest, "AndroidManifest.xml");
const root = document.documentElement;
const parsed = parseXmlDocument(manifest, {
doctype: "allow",
lineEndings: "preserve",
});
if (!parsed.ok)
throw new TypeError(
`AndroidManifest.xml is not well-formed XML: ${parsed.error.message}`,
{ cause: parsed.error },
);
const root = parsed.value.documentElement;
if (root === null || root.nodeName !== "manifest")
throw new TypeError("AndroidManifest.xml root must be <manifest>");
return root.getAttribute("package") || null;
@@ -65,6 +74,18 @@ export interface DecodedStringEntry {
readonly value: string;
}
/** A whole-document failure does not establish an omitted-entry count. */
export class ApktoolStringsDocumentError extends Error {
readonly _tag = "ApktoolStringsDocumentError";
constructor(
readonly reason: "malformed" | "wrong_root",
message: string,
options?: ErrorOptions,
) {
super(message, options);
}
}
/**
* Project direct `<string>` children from decoded resources XML. DOM parsing
* identifies elements and attributes; text values are sliced from the input
@@ -73,19 +94,31 @@ export interface DecodedStringEntry {
*/
export const parseDecodedStrings = (
stringsXml: string,
): {
entries: readonly DecodedStringEntry[];
unparsedCount: number;
} => {
let document: Document;
try {
document = parseXml(stringsXml, "strings.xml");
} catch {
return { entries: [], unparsedCount: 1 };
}
const root = document.documentElement;
): Result<
{
readonly entries: readonly DecodedStringEntry[];
readonly unparsedCount: number;
},
ApktoolStringsDocumentError
> => {
const parsed = parseXmlDocument(stringsXml, {
doctype: "allow",
lineEndings: "preserve",
});
if (!parsed.ok)
return err(
new ApktoolStringsDocumentError("malformed", parsed.error.message, {
cause: parsed.error,
}),
);
const root = parsed.value.documentElement;
if (root === null || root.nodeName !== "resources")
return { entries: [], unparsedCount: 1 };
return err(
new ApktoolStringsDocumentError(
"wrong_root",
"String resource document root must be <resources>",
),
);
const entries: DecodedStringEntry[] = [];
let unparsedCount = 0;
@@ -125,7 +158,7 @@ export const parseDecodedStrings = (
}
entries.push({ name, value: value.join("") });
}
return { entries, unparsedCount };
return ok({ entries, unparsedCount });
};
const yamlScalar = (
@@ -142,19 +175,6 @@ const yamlScalar = (
: source.slice(range[0], range[1]);
};
const parseXml = (xml: string, label: string) => {
const parsed = parseXmlDocument(xml, {
doctype: "allow",
lineEndings: "preserve",
});
if (!parsed.ok)
throw new TypeError(
`${label} is not well-formed XML: ${parsed.error.message}`,
{ cause: parsed.error },
);
return parsed.value;
};
const childNodes = (node: Node): Node[] => {
const children: Node[] = [];
for (let child = node.firstChild; child !== null; child = child.nextSibling)