mirror of
https://github.com/morluto/rea.git
synced 2026-10-19 04:19:54 -05:00
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>
335 lines
9.7 KiB
TypeScript
335 lines
9.7 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { ok } from "../domain/result.js";
|
|
|
|
import {
|
|
parseApktoolYml,
|
|
parseDecodedStrings,
|
|
parseManifestPackage,
|
|
} from "./ApktoolDecodeOutput.js";
|
|
|
|
const REAL_YML = [
|
|
"!!brut.androlib.meta.MetaInfo",
|
|
"apkFileName: probe.apk",
|
|
"compressionType: false",
|
|
"doNotCompress:",
|
|
"- resources.arsc",
|
|
"isFrameworkApk: false",
|
|
"packageInfo:",
|
|
" forcedPackageId: '127'",
|
|
" renameManifestPackage: null",
|
|
"sdkInfo:",
|
|
" minSdkVersion: '24'",
|
|
" targetSdkVersion: '34'",
|
|
"sharedLibrary: false",
|
|
"sparseResources: false",
|
|
"unknownFiles: {}",
|
|
"usesFramework:",
|
|
" ids:",
|
|
" - 1",
|
|
" tag: null",
|
|
"versionInfo:",
|
|
" versionCode: '7'",
|
|
" versionName: 1.2.3",
|
|
"",
|
|
].join("\n");
|
|
|
|
describe("parseApktoolYml", () => {
|
|
it("projects version and SDK facts from a real apktool.yml", () => {
|
|
expect(parseApktoolYml(REAL_YML)).toEqual({
|
|
versionName: "1.2.3",
|
|
versionCode: "7",
|
|
minSdkVersion: "24",
|
|
targetSdkVersion: "34",
|
|
});
|
|
});
|
|
|
|
it("accepts quoted version values some builds emit", () => {
|
|
expect(
|
|
parseApktoolYml(
|
|
'!!brut.androlib.meta.MetaInfo\nversionInfo:\n versionName: "2.0.0"\n versionCode: 45\n',
|
|
),
|
|
).toEqual({
|
|
versionName: "2.0.0",
|
|
versionCode: "45",
|
|
minSdkVersion: null,
|
|
targetSdkVersion: null,
|
|
});
|
|
});
|
|
|
|
it("reads only the producer's nested fields and preserves scalar spelling", () => {
|
|
expect(
|
|
parseApktoolYml(
|
|
[
|
|
"!!brut.androlib.meta.MetaInfo",
|
|
"versionName: decoy",
|
|
"versionInfo:",
|
|
" versionName: 01.2.3",
|
|
" versionCode: 045",
|
|
" custom:",
|
|
" versionName: unrelated",
|
|
"sdkInfo:",
|
|
" minSdkVersion: 24",
|
|
" targetSdkVersion: '35'",
|
|
"",
|
|
].join("\n"),
|
|
),
|
|
).toEqual({
|
|
versionName: "01.2.3",
|
|
versionCode: "045",
|
|
minSdkVersion: "24",
|
|
targetSdkVersion: "35",
|
|
});
|
|
});
|
|
|
|
it("distinguishes YAML nulls from quoted null strings in flow mappings", () => {
|
|
expect(
|
|
parseApktoolYml(
|
|
[
|
|
"!!brut.androlib.meta.MetaInfo",
|
|
'versionInfo: { versionName: "null", versionCode: 0045, nested: { versionName: decoy } }',
|
|
"sdkInfo: { minSdkVersion: null, targetSdkVersion: ~ }",
|
|
"",
|
|
].join("\n"),
|
|
),
|
|
).toEqual({
|
|
versionName: "null",
|
|
versionCode: "0045",
|
|
minSdkVersion: null,
|
|
targetSdkVersion: null,
|
|
});
|
|
});
|
|
|
|
it("reports null facts for unrelated YAML", () => {
|
|
expect(parseApktoolYml("version: 2.7.0\n")).toEqual({
|
|
versionName: null,
|
|
versionCode: null,
|
|
minSdkVersion: null,
|
|
targetSdkVersion: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("parseManifestPackage", () => {
|
|
it("reads the package attribute from a decoded manifest", () => {
|
|
const manifest =
|
|
'<?xml version="1.0" encoding="utf-8" standalone="no"?><manifest xmlns:android="http://schemas.android.com/apk/res/android" android:compileSdkVersion="34" package="com.rea.apktool.probe" platformBuildVersionCode="34">';
|
|
expect(parseManifestPackage(`${manifest}</manifest>`)).toBe(
|
|
"com.rea.apktool.probe",
|
|
);
|
|
});
|
|
|
|
it("reads package with reordered or single-quoted XML attributes", () => {
|
|
expect(
|
|
parseManifestPackage("<manifest debug='false' package='org.rea.app' />"),
|
|
).toBe("org.rea.app");
|
|
});
|
|
|
|
it("reports null when no package attribute exists", () => {
|
|
expect(parseManifestPackage("<manifest>\n</manifest>\n")).toBeNull();
|
|
});
|
|
|
|
it("reads the package when unrelated manifest text contains U+FFFD", () => {
|
|
expect(
|
|
parseManifestPackage(
|
|
'<manifest package="org.rea.app"><application label="\uFFFD"/></manifest>',
|
|
),
|
|
).toBe("org.rea.app");
|
|
});
|
|
|
|
it("still rejects malformed attributes alongside U+FFFD", () => {
|
|
expect(() =>
|
|
parseManifestPackage(
|
|
'<manifest package=org.rea.app><application label="\uFFFD"/></manifest>',
|
|
),
|
|
).toThrow(/AndroidManifest.xml is not well-formed XML/u);
|
|
});
|
|
|
|
it("rejects a well-formed document with the wrong root element", () => {
|
|
expect(() => parseManifestPackage("<resources/>")).toThrowError(
|
|
new TypeError("AndroidManifest.xml root must be <manifest>"),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("parseDecodedStrings", () => {
|
|
it("projects entries from a real decoded strings.xml", () => {
|
|
const stringsXml = [
|
|
'<?xml version="1.0" encoding="utf-8"?>',
|
|
"<resources>",
|
|
' <string name="api_endpoint">https://api.example.com/v1</string>',
|
|
' <string name="app_name">ApktoolProbe</string>',
|
|
' <string name="debug_flag">true</string>',
|
|
"</resources>",
|
|
"",
|
|
].join("\n");
|
|
expect(parseDecodedStrings(stringsXml)).toEqual(
|
|
ok({
|
|
entries: [
|
|
{ name: "api_endpoint", value: "https://api.example.com/v1" },
|
|
{ name: "app_name", value: "ApktoolProbe" },
|
|
{ name: "debug_flag", value: "true" },
|
|
],
|
|
unparsedCount: 0,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("counts entries with nested markup instead of flattening them", () => {
|
|
const stringsXml =
|
|
'<resources><string name="styled">Hello <b>world</b></string><string name="plain">ok</string></resources>';
|
|
expect(parseDecodedStrings(stringsXml)).toEqual(
|
|
ok({
|
|
entries: [{ name: "plain", value: "ok" }],
|
|
unparsedCount: 1,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("preserves escaped entities verbatim", () => {
|
|
const stringsXml =
|
|
'<resources><string name="escaped">a & b <c></string></resources>';
|
|
expect(parseDecodedStrings(stringsXml)).toEqual(
|
|
ok({
|
|
entries: [{ name: "escaped", value: "a & b <c>" }],
|
|
unparsedCount: 0,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("preserves literal U+FFFD and unaffected sibling strings", () => {
|
|
expect(
|
|
parseDecodedStrings(
|
|
'<resources><string name="good">Hello</string><string name="edge">\uFFFD</string></resources>',
|
|
),
|
|
).toEqual(
|
|
ok({
|
|
entries: [
|
|
{ name: "good", value: "Hello" },
|
|
{ name: "edge", value: "\uFFFD" },
|
|
],
|
|
unparsedCount: 0,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
"<resources><string name=unquoted>\uFFFD</string></resources>",
|
|
'<resources><string name="broken">\uFFFD</resources>',
|
|
])("still rejects malformed XML containing U+FFFD: %s", (xml) => {
|
|
expect(parseDecodedStrings(xml)).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "ApktoolStringsDocumentError",
|
|
reason: "malformed",
|
|
cause: { _tag: "XmlDocumentParseError" },
|
|
},
|
|
});
|
|
});
|
|
|
|
it("keeps empty, self-closing, and reordered string attributes", () => {
|
|
expect(
|
|
parseDecodedStrings(
|
|
'<resources><string translatable="false" name="empty"/><string name="paired"></string></resources>',
|
|
),
|
|
).toEqual(
|
|
ok({
|
|
entries: [
|
|
{ name: "empty", value: "" },
|
|
{ name: "paired", value: "" },
|
|
],
|
|
unparsedCount: 0,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("ignores comments without mistaking them for nested markup", () => {
|
|
expect(
|
|
parseDecodedStrings(
|
|
'<resources><!-- <string name="fake">bad</string> --><string name="real">before<!-- note -->after</string></resources>',
|
|
),
|
|
).toEqual(
|
|
ok({
|
|
entries: [{ name: "real", value: "beforeafter" }],
|
|
unparsedCount: 0,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not include a trailing document comment in the last string", () => {
|
|
expect(
|
|
parseDecodedStrings(
|
|
'<resources><string name="a">one</string></resources><!-- </string> -->',
|
|
),
|
|
).toEqual(
|
|
ok({
|
|
entries: [{ name: "a", value: "one" }],
|
|
unparsedCount: 0,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("preserves source whitespace and entity spelling", () => {
|
|
expect(
|
|
parseDecodedStrings(
|
|
'<resources><string name="raw"> a & b </string></resources>',
|
|
),
|
|
).toEqual(
|
|
ok({
|
|
entries: [{ name: "raw", value: " a & b " }],
|
|
unparsedCount: 0,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps source slices aligned across CRLF and CR line endings", () => {
|
|
for (const newline of ["\r\n", "\r"]) {
|
|
expect(
|
|
parseDecodedStrings(
|
|
`<resources>${newline} <string name="raw">x & y</string>${newline}</resources>`,
|
|
),
|
|
).toEqual(
|
|
ok({
|
|
entries: [{ name: "raw", value: "x & y" }],
|
|
unparsedCount: 0,
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("string document validity and omission counts", () => {
|
|
it("counts a string with no name as unsupported instead of emitting an empty name", () => {
|
|
expect(
|
|
parseDecodedStrings("<resources><string>value</string></resources>"),
|
|
).toEqual(ok({ entries: [], unparsedCount: 1 }));
|
|
});
|
|
|
|
it("distinguishes malformed XML from counted unsupported string markup", () => {
|
|
expect(
|
|
parseDecodedStrings("<resources><string name='broken'>no</resources>"),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "ApktoolStringsDocumentError", reason: "malformed" },
|
|
});
|
|
expect(
|
|
parseDecodedStrings(
|
|
'<resources><string name="styled">Hello <b>world</b></string></resources>',
|
|
),
|
|
).toEqual(ok({ entries: [], unparsedCount: 1 }));
|
|
});
|
|
});
|
|
|
|
it.each(["<manifest/>", '<other><string name="a">one</string></other>'])(
|
|
"keeps a wrong document root separate from an entry count: %s",
|
|
(xml) => {
|
|
const parsed = parseDecodedStrings(xml);
|
|
expect(parsed).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "ApktoolStringsDocumentError", reason: "wrong_root" },
|
|
});
|
|
expect(parsed).not.toHaveProperty("value");
|
|
if (parsed.ok) throw new Error("Expected document failure");
|
|
expect(parsed.error).not.toHaveProperty("unparsedCount");
|
|
},
|
|
);
|