@dailephd/my-dev-kit-lab 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -269
- package/dist/scripts/security/validate.js +177 -70
- package/dist/src/audits/codeRot/detectors/architectureDriftDetector.js +5 -2
- package/dist/src/audits/codeRot/detectors/securityValidationAssumptionRotDetector.js +5 -2
- package/dist/src/audits/core/collectSourceFacts.js +21 -10
- package/dist/src/mobile/android/audit/androidFinding.js +68 -0
- package/dist/src/mobile/android/audit/checkResultBuilder.js +97 -0
- package/dist/src/mobile/android/audit/deepLinkAudit.js +183 -0
- package/dist/src/mobile/android/audit/exportedComponentAudit.js +183 -0
- package/dist/src/mobile/android/audit/intentFilterAudit.js +128 -0
- package/dist/src/mobile/android/audit/permissionAudit.js +142 -0
- package/dist/src/mobile/android/detect/androidTargetDetection.js +18 -0
- package/dist/src/mobile/android/detect/buildFileEvidence.js +36 -0
- package/dist/src/mobile/android/detect/classify.js +150 -0
- package/dist/src/mobile/android/detect/detectAndroidProject.js +252 -0
- package/dist/src/mobile/android/detect/settingsModules.js +60 -0
- package/dist/src/mobile/android/detect/sourceEvidence.js +92 -0
- package/dist/src/mobile/android/detect/traversal.js +79 -0
- package/dist/src/mobile/android/detect/versionCatalogEvidence.js +37 -0
- package/dist/src/mobile/android/detection.js +28 -0
- package/dist/src/mobile/android/gradle/gradleMetadataCheckResult.js +120 -0
- package/dist/src/mobile/android/gradle/moduleMetadataExtractor.js +137 -0
- package/dist/src/mobile/android/gradle/pluginVersionExtractor.js +40 -0
- package/dist/src/mobile/android/gradle/readAndroidGradleMetadata.js +210 -0
- package/dist/src/mobile/android/gradle/releaseMetadataSummary.js +64 -0
- package/dist/src/mobile/android/gradle/settingsMetadata.js +20 -0
- package/dist/src/mobile/android/gradle/types.js +1 -0
- package/dist/src/mobile/android/gradle/validate/executor.js +9 -0
- package/dist/src/mobile/android/gradle/validate/operationCheckResult.js +99 -0
- package/dist/src/mobile/android/gradle/validate/operations.js +68 -0
- package/dist/src/mobile/android/gradle/validate/planner.js +49 -0
- package/dist/src/mobile/android/gradle/validate/runOptionalGradleValidation.js +113 -0
- package/dist/src/mobile/android/gradle/validate/targetMutation.js +45 -0
- package/dist/src/mobile/android/gradle/validate/taskListParser.js +24 -0
- package/dist/src/mobile/android/gradle/versionCatalogMetadata.js +94 -0
- package/dist/src/mobile/android/gradle/wrapperMetadata.js +32 -0
- package/dist/src/mobile/android/manifest/parseAndroidManifest.js +341 -0
- package/dist/src/mobile/android/manifest/types.js +29 -0
- package/dist/src/mobile/android/manifest/xml/parseXml.js +313 -0
- package/dist/src/mobile/android/profile.js +29 -0
- package/dist/src/mobile/android/report/model.js +104 -0
- package/dist/src/mobile/android/report/renderAndroidReport.js +223 -0
- package/dist/src/mobile/android/report/writeAndroidReportFiles.js +23 -0
- package/dist/src/mobile/android/targetMetadata.js +27 -0
- package/dist/src/mobile/android/validate/androidVerdict.js +248 -0
- package/dist/src/mobile/android/validate/detectionAndImmutabilityCheckResults.js +177 -0
- package/dist/src/mobile/android/validate/playReadinessChecklist.js +145 -0
- package/dist/src/mobile/android/validate/validateAndroidTarget.js +172 -0
- package/dist/src/mobile/android/validation/checkResult.js +51 -0
- package/dist/src/mobile/android/validation/result.js +25 -0
- package/dist/src/mobile/index.js +42 -0
- package/dist/src/mobile/types.js +27 -0
- package/dist/src/securityValidation/attackScenarios/attackProfile.js +2 -0
- package/dist/src/securityValidation/attackScenarios/profiles/androidProfile.js +20 -0
- package/dist/src/securityValidation/validate/cliOptions.js +46 -2
- package/package.json +2 -1
- package/tests/fixtures/android/compose-app/app/build.gradle.kts +21 -0
- package/tests/fixtures/android/compose-app/app/src/main/AndroidManifest.xml +22 -0
- package/tests/fixtures/android/compose-app/app/src/main/java/com/example/composeapp/MainActivity.kt +11 -0
- package/tests/fixtures/android/compose-app/build.gradle.kts +4 -0
- package/tests/fixtures/android/compose-app/gradle/wrapper/gradle-wrapper.properties +4 -0
- package/tests/fixtures/android/compose-app/settings.gradle.kts +2 -0
- package/tests/fixtures/android/gradle-metadata-fixtures/catalog-only-plugin/build.gradle.kts +3 -0
- package/tests/fixtures/android/gradle-metadata-fixtures/catalog-only-plugin/gradle/libs.versions.toml +5 -0
- package/tests/fixtures/android/gradle-metadata-fixtures/comment-safety.gradle.kts +21 -0
- package/tests/fixtures/android/gradle-metadata-fixtures/dynamic-values.gradle.kts +17 -0
- package/tests/fixtures/android/gradle-metadata-fixtures/groovy-literal.gradle +23 -0
- package/tests/fixtures/android/gradle-metadata-fixtures/wrapper-malformed.properties +4 -0
- package/tests/fixtures/android/gradle-metadata-fixtures/wrapper-missing-url.properties +3 -0
- package/tests/fixtures/android/gradle-metadata-fixtures/wrapper-with-checksum.properties +5 -0
- package/tests/fixtures/android/java-app/app/build.gradle +16 -0
- package/tests/fixtures/android/java-app/app/src/main/AndroidManifest.xml +19 -0
- package/tests/fixtures/android/java-app/app/src/main/java/com/example/javaapp/MainActivity.java +4 -0
- package/tests/fixtures/android/java-app/build.gradle +3 -0
- package/tests/fixtures/android/java-app/gradle/wrapper/gradle-wrapper.properties +4 -0
- package/tests/fixtures/android/java-app/settings.gradle +2 -0
- package/tests/fixtures/android/library/build.gradle.kts +3 -0
- package/tests/fixtures/android/library/gradle/wrapper/gradle-wrapper.properties +4 -0
- package/tests/fixtures/android/library/mylibrary/build.gradle.kts +13 -0
- package/tests/fixtures/android/library/mylibrary/src/main/AndroidManifest.xml +2 -0
- package/tests/fixtures/android/library/settings.gradle.kts +2 -0
- package/tests/fixtures/android/manifest-audit-fixtures/incomplete-intent-filter.xml +14 -0
- package/tests/fixtures/android/manifest-audit-fixtures/malformed-exported.xml +10 -0
- package/tests/fixtures/android/manifest-audit-fixtures/malformed.xml +5 -0
- package/tests/fixtures/android/manifest-audit-fixtures/missing-application.xml +7 -0
- package/tests/fixtures/android/manifest-audit-fixtures/missing-component-name.xml +11 -0
- package/tests/fixtures/android/manifest-audit-fixtures/permission-coverage.xml +29 -0
- package/tests/fixtures/android/manifest-audit-fixtures/risky-components.xml +62 -0
- package/tests/fixtures/android/manifest-audit-fixtures/risky-deep-links.xml +45 -0
- package/tests/fixtures/android/manifest-audit-fixtures/unresolved-placeholder.xml +14 -0
- package/tests/fixtures/android/manifest-audit-fixtures/unusual-namespace-prefix.xml +11 -0
- package/tests/fixtures/android/multi-module/app/build.gradle.kts +22 -0
- package/tests/fixtures/android/multi-module/app/src/androidTest/java/com/example/multimodule/app/ExampleInstrumentedTest.kt +3 -0
- package/tests/fixtures/android/multi-module/app/src/main/AndroidManifest.xml +11 -0
- package/tests/fixtures/android/multi-module/app/src/test/java/com/example/multimodule/app/ExampleUnitTest.kt +3 -0
- package/tests/fixtures/android/multi-module/build.gradle.kts +5 -0
- package/tests/fixtures/android/multi-module/core/build.gradle.kts +14 -0
- package/tests/fixtures/android/multi-module/core/src/main/AndroidManifest.xml +2 -0
- package/tests/fixtures/android/multi-module/feature-login/build.gradle.kts +18 -0
- package/tests/fixtures/android/multi-module/feature-login/src/main/AndroidManifest.xml +2 -0
- package/tests/fixtures/android/multi-module/gradle/libs.versions.toml +8 -0
- package/tests/fixtures/android/multi-module/gradle/wrapper/gradle-wrapper.properties +4 -0
- package/tests/fixtures/android/multi-module/settings.gradle.kts +4 -0
- package/tests/fixtures/android/non-android-gradle/build.gradle.kts +3 -0
- package/tests/fixtures/android/non-android-gradle/core/build.gradle.kts +3 -0
- package/tests/fixtures/android/non-android-gradle/core/src/main/java/com/example/core/Core.java +4 -0
- package/tests/fixtures/android/non-android-gradle/gradle/wrapper/gradle-wrapper.properties +4 -0
- package/tests/fixtures/android/non-android-gradle/settings.gradle.kts +2 -0
- package/tests/fixtures/android/partial/app/build.gradle.kts +7 -0
- package/tests/fixtures/android/partial/settings.gradle.kts +2 -0
- package/tests/fixtures/android/xml-view-app/app/build.gradle.kts +17 -0
- package/tests/fixtures/android/xml-view-app/app/src/main/AndroidManifest.xml +31 -0
- package/tests/fixtures/android/xml-view-app/app/src/main/kotlin/com/example/xmlviewapp/MainActivity.kt +7 -0
- package/tests/fixtures/android/xml-view-app/app/src/main/res/layout/activity_main.xml +4 -0
- package/tests/fixtures/android/xml-view-app/build.gradle.kts +4 -0
- package/tests/fixtures/android/xml-view-app/gradle/wrapper/gradle-wrapper.properties +4 -0
- package/tests/fixtures/android/xml-view-app/settings.gradle.kts +2 -0
- package/tests/fixtures/audits/mixed-language/mixed-docs-claims/README.md +16 -0
- package/tests/fixtures/audits/mixed-language/mixed-docs-claims/build.gradle.kts +4 -0
- package/tests/fixtures/audits/mixed-language/mixed-docs-claims/src/main/java/com/example/Widget.java +7 -0
- package/tests/fixtures/audits/mixed-language/mixed-docs-claims/src/main/kotlin/com/example/Gadget.kt +7 -0
- package/tests/fixtures/audits/mixed-language/mixed-generated-vendor/README.md +10 -0
- package/tests/fixtures/audits/mixed-language/mixed-generated-vendor/build/Main.class +1 -0
- package/tests/fixtures/audits/mixed-language/mixed-generated-vendor/dist/index.js +1 -0
- package/tests/fixtures/audits/mixed-language/mixed-generated-vendor/generated/schema.ts +1 -0
- package/tests/fixtures/audits/mixed-language/mixed-generated-vendor/src/index.ts +3 -0
- package/tests/fixtures/audits/mixed-language/mixed-generated-vendor/vendor/lib.py +2 -0
- package/tests/fixtures/audits/mixed-language/mixed-java-kotlin/README.md +10 -0
- package/tests/fixtures/audits/mixed-language/mixed-java-kotlin/build.gradle.kts +8 -0
- package/tests/fixtures/audits/mixed-language/mixed-java-kotlin/settings.gradle.kts +1 -0
- package/tests/fixtures/audits/mixed-language/mixed-java-kotlin/src/main/java/com/example/FooService.java +7 -0
- package/tests/fixtures/audits/mixed-language/mixed-java-kotlin/src/main/kotlin/com/example/BarService.kt +7 -0
- package/tests/fixtures/audits/mixed-language/mixed-java-kotlin/src/test/java/com/example/FooServiceTest.java +8 -0
- package/tests/fixtures/audits/mixed-language/mixed-java-kotlin/src/test/kotlin/com/example/BarServiceTest.kt +8 -0
- package/tests/fixtures/audits/mixed-language/mixed-python-ts/README.md +8 -0
- package/tests/fixtures/audits/mixed-language/mixed-python-ts/src/app.py +7 -0
- package/tests/fixtures/audits/mixed-language/mixed-python-ts/src/index.ts +3 -0
- package/tests/fixtures/audits/mixed-language/mixed-python-ts/tests/test_app.py +5 -0
- package/tests/fixtures/audits/mixed-language/mixed-ts-js/README.md +7 -0
- package/tests/fixtures/audits/mixed-language/mixed-ts-js/src/helper.js +5 -0
- package/tests/fixtures/audits/mixed-language/mixed-ts-js/src/index.ts +3 -0
- package/tests/fixtures/audits/mixed-language/mixed-ts-js/tests/index.sample.ts +5 -0
- package/tests/fixtures/audits/mixed-language/mixed-ts-python-jvm/README.md +10 -0
- package/tests/fixtures/audits/mixed-language/mixed-ts-python-jvm/build.gradle.kts +4 -0
- package/tests/fixtures/audits/mixed-language/mixed-ts-python-jvm/pytests/test_app.py +5 -0
- package/tests/fixtures/audits/mixed-language/mixed-ts-python-jvm/src/app.py +2 -0
- package/tests/fixtures/audits/mixed-language/mixed-ts-python-jvm/src/helper.js +5 -0
- package/tests/fixtures/audits/mixed-language/mixed-ts-python-jvm/src/index.ts +3 -0
- package/tests/fixtures/audits/mixed-language/mixed-ts-python-jvm/src/main/java/com/example/Multiplier.java +7 -0
- package/tests/fixtures/audits/mixed-language/mixed-ts-python-jvm/src/main/kotlin/com/example/Divider.kt +7 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { relativeWithinRoot, resolveWithinRoot } from "../../../core/pathSafety.js";
|
|
4
|
+
import { ANDROID_NAMESPACE_URI, findChildren, getAttribute, parseXmlDocument, } from "./xml/parseXml.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// v0.4.0 Batch 3 — AndroidManifest.xml parser.
|
|
7
|
+
//
|
|
8
|
+
// Maps the bounded XML tree from ./xml/parseXml.ts onto the Batch 1
|
|
9
|
+
// AndroidManifestModel contract. Static, deterministic, read-only: no
|
|
10
|
+
// resource-reference resolution, no manifest-placeholder resolution (values
|
|
11
|
+
// like @string/app_name and ${applicationId} are preserved verbatim), and no
|
|
12
|
+
// manifest merging. Malformed input always produces a valid (possibly
|
|
13
|
+
// mostly-empty) AndroidManifestModel with the problem recorded in
|
|
14
|
+
// `parseWarnings`/`unsupportedConstructs` rather than throwing.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
function toLocation(xmlLocation) {
|
|
17
|
+
return { line: xmlLocation.line, column: xmlLocation.column };
|
|
18
|
+
}
|
|
19
|
+
function getUnprefixedAttribute(element, localName) {
|
|
20
|
+
return element.attributes.find((attr) => attr.prefix === undefined && attr.localName === localName)?.value;
|
|
21
|
+
}
|
|
22
|
+
function androidAttr(element, localName) {
|
|
23
|
+
return getAttribute(element, localName, ANDROID_NAMESPACE_URI)?.value;
|
|
24
|
+
}
|
|
25
|
+
function parseBooleanAttr(element, localName, warnings, context) {
|
|
26
|
+
const attr = getAttribute(element, localName, ANDROID_NAMESPACE_URI);
|
|
27
|
+
if (!attr)
|
|
28
|
+
return {};
|
|
29
|
+
if (attr.value === "true")
|
|
30
|
+
return { value: true, raw: attr.value };
|
|
31
|
+
if (attr.value === "false")
|
|
32
|
+
return { value: false, raw: attr.value };
|
|
33
|
+
warnings.push(`${context}: android:${localName} has an unresolved or malformed value "${attr.value}" (line ${attr.location.line}) — not normalized to true or false.`);
|
|
34
|
+
return { raw: attr.value };
|
|
35
|
+
}
|
|
36
|
+
function parseIntAttr(element, localName, warnings, context) {
|
|
37
|
+
const attr = getAttribute(element, localName, ANDROID_NAMESPACE_URI);
|
|
38
|
+
if (!attr)
|
|
39
|
+
return undefined;
|
|
40
|
+
const parsed = Number.parseInt(attr.value, 10);
|
|
41
|
+
if (Number.isNaN(parsed)) {
|
|
42
|
+
warnings.push(`${context}: android:${localName} is not a resolvable integer ("${attr.value}", line ${attr.location.line}).`);
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
return parsed;
|
|
46
|
+
}
|
|
47
|
+
const LAUNCHER_ACTION = "android.intent.action.MAIN";
|
|
48
|
+
const LAUNCHER_CATEGORY = "android.intent.category.LAUNCHER";
|
|
49
|
+
const VIEW_ACTION = "android.intent.action.VIEW";
|
|
50
|
+
const BROWSABLE_CATEGORY = "android.intent.category.BROWSABLE";
|
|
51
|
+
function parseIntentFilter(element, warnings) {
|
|
52
|
+
const actions = findChildren(element, "action")
|
|
53
|
+
.map((a) => androidAttr(a, "name"))
|
|
54
|
+
.filter((v) => Boolean(v));
|
|
55
|
+
const categories = findChildren(element, "category")
|
|
56
|
+
.map((c) => androidAttr(c, "name"))
|
|
57
|
+
.filter((v) => Boolean(v));
|
|
58
|
+
const autoVerifyResult = parseBooleanAttr(element, "autoVerify", warnings, "intent-filter");
|
|
59
|
+
const dataElements = findChildren(element, "data").map((d) => ({
|
|
60
|
+
dataScheme: androidAttr(d, "scheme"),
|
|
61
|
+
dataHost: androidAttr(d, "host"),
|
|
62
|
+
dataPath: androidAttr(d, "path"),
|
|
63
|
+
dataPathPrefix: androidAttr(d, "pathPrefix"),
|
|
64
|
+
dataPathPattern: androidAttr(d, "pathPattern"),
|
|
65
|
+
autoVerify: autoVerifyResult.value,
|
|
66
|
+
location: toLocation(d.location),
|
|
67
|
+
}));
|
|
68
|
+
const isDeepLinkCandidate = actions.includes(VIEW_ACTION) && (categories.includes(BROWSABLE_CATEGORY) || dataElements.length > 0);
|
|
69
|
+
for (const child of element.children) {
|
|
70
|
+
if (child.localName !== "action" && child.localName !== "category" && child.localName !== "data") {
|
|
71
|
+
warnings.push(`Unsupported intent-filter child element <${child.tagName}> at line ${child.location.line} (preserved as a warning, not parsed).`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
filterData: dataElements,
|
|
76
|
+
actions,
|
|
77
|
+
categories,
|
|
78
|
+
dataElements,
|
|
79
|
+
autoVerify: autoVerifyResult.value,
|
|
80
|
+
isDeepLinkCandidate,
|
|
81
|
+
location: toLocation(element.location),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function parseComponent(kind, element, warnings) {
|
|
85
|
+
const context = `<${kind}> at line ${element.location.line}`;
|
|
86
|
+
const name = androidAttr(element, "name");
|
|
87
|
+
if (!name) {
|
|
88
|
+
warnings.push(`${context} is missing android:name.`);
|
|
89
|
+
}
|
|
90
|
+
const exportedResult = parseBooleanAttr(element, "exported", warnings, context);
|
|
91
|
+
const enabledResult = parseBooleanAttr(element, "enabled", warnings, context);
|
|
92
|
+
const permission = androidAttr(element, "permission");
|
|
93
|
+
const intentFilters = findChildren(element, "intent-filter").map((filterEl) => parseIntentFilter(filterEl, warnings));
|
|
94
|
+
const isLauncherActivity = (kind === "activity" || kind === "activity-alias") &&
|
|
95
|
+
intentFilters.some((filter) => filter.actions?.includes(LAUNCHER_ACTION) && filter.categories?.includes(LAUNCHER_CATEGORY));
|
|
96
|
+
const component = {
|
|
97
|
+
kind,
|
|
98
|
+
name: name ?? "",
|
|
99
|
+
exported: exportedResult.value,
|
|
100
|
+
exportedRaw: exportedResult.raw,
|
|
101
|
+
enabled: enabledResult.value,
|
|
102
|
+
permission,
|
|
103
|
+
intentFilters,
|
|
104
|
+
isLauncherActivity: isLauncherActivity || undefined,
|
|
105
|
+
location: toLocation(element.location),
|
|
106
|
+
};
|
|
107
|
+
if (kind === "provider") {
|
|
108
|
+
component.readPermission = androidAttr(element, "readPermission");
|
|
109
|
+
component.writePermission = androidAttr(element, "writePermission");
|
|
110
|
+
const authoritiesRaw = androidAttr(element, "authorities");
|
|
111
|
+
component.authorities = authoritiesRaw ? authoritiesRaw.split(";").map((s) => s.trim()).filter(Boolean) : undefined;
|
|
112
|
+
component.grantUriPermissions = parseBooleanAttr(element, "grantUriPermissions", warnings, context).value;
|
|
113
|
+
}
|
|
114
|
+
return component;
|
|
115
|
+
}
|
|
116
|
+
// Parses AndroidManifest.xml source text into the normalized contract. Pure
|
|
117
|
+
// (no filesystem access) so it can be unit tested directly against inline
|
|
118
|
+
// XML strings.
|
|
119
|
+
export function parseAndroidManifestSource(xmlText, manifestPath) {
|
|
120
|
+
const parseWarnings = [];
|
|
121
|
+
const unsupportedConstructs = [];
|
|
122
|
+
const parsed = parseXmlDocument(xmlText);
|
|
123
|
+
for (const warning of parsed.warnings) {
|
|
124
|
+
parseWarnings.push(`${warning.message} (line ${warning.location.line}, column ${warning.location.column})`);
|
|
125
|
+
}
|
|
126
|
+
if (!parsed.ok) {
|
|
127
|
+
parseWarnings.push(`Malformed XML: ${parsed.error.message} (line ${parsed.error.location.line}, column ${parsed.error.location.column})`);
|
|
128
|
+
return {
|
|
129
|
+
manifestPath,
|
|
130
|
+
application: {},
|
|
131
|
+
permissions: [],
|
|
132
|
+
usesFeatures: [],
|
|
133
|
+
activities: [],
|
|
134
|
+
activityAliases: [],
|
|
135
|
+
services: [],
|
|
136
|
+
receivers: [],
|
|
137
|
+
providers: [],
|
|
138
|
+
deepLinks: [],
|
|
139
|
+
parseWarnings,
|
|
140
|
+
unsupportedConstructs,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const root = parsed.root;
|
|
144
|
+
if (root.localName !== "manifest") {
|
|
145
|
+
parseWarnings.push(`Root element is <${root.tagName}>, not <manifest> — manifest could not be interpreted.`);
|
|
146
|
+
return {
|
|
147
|
+
manifestPath,
|
|
148
|
+
application: {},
|
|
149
|
+
permissions: [],
|
|
150
|
+
usesFeatures: [],
|
|
151
|
+
activities: [],
|
|
152
|
+
activityAliases: [],
|
|
153
|
+
services: [],
|
|
154
|
+
receivers: [],
|
|
155
|
+
providers: [],
|
|
156
|
+
deepLinks: [],
|
|
157
|
+
parseWarnings,
|
|
158
|
+
unsupportedConstructs,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const packageName = getUnprefixedAttribute(root, "package");
|
|
162
|
+
const permissionElementNames = [
|
|
163
|
+
["uses-permission", "uses-permission"],
|
|
164
|
+
["uses-permission-sdk-23", "uses-permission-sdk-23"],
|
|
165
|
+
["uses-permission-sdk-m", "uses-permission-sdk-m"],
|
|
166
|
+
];
|
|
167
|
+
const permissions = [];
|
|
168
|
+
for (const [elementName, sourceElement] of permissionElementNames) {
|
|
169
|
+
for (const el of findChildren(root, elementName)) {
|
|
170
|
+
const name = androidAttr(el, "name");
|
|
171
|
+
if (!name) {
|
|
172
|
+
parseWarnings.push(`<${elementName}> at line ${el.location.line} is missing android:name.`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
permissions.push({
|
|
176
|
+
name,
|
|
177
|
+
maxSdkVersion: parseIntAttr(el, "maxSdkVersion", parseWarnings, `<${elementName}> "${name}"`),
|
|
178
|
+
sourceElement,
|
|
179
|
+
location: toLocation(el.location),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const usesFeatures = findChildren(root, "uses-feature")
|
|
184
|
+
.map((el) => {
|
|
185
|
+
const name = androidAttr(el, "name");
|
|
186
|
+
if (!name) {
|
|
187
|
+
parseWarnings.push(`<uses-feature> at line ${el.location.line} is missing android:name.`);
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
name,
|
|
192
|
+
required: parseBooleanAttr(el, "required", parseWarnings, `<uses-feature> "${name}"`).value,
|
|
193
|
+
location: toLocation(el.location),
|
|
194
|
+
};
|
|
195
|
+
})
|
|
196
|
+
.filter((v) => v !== undefined);
|
|
197
|
+
const applicationElements = findChildren(root, "application");
|
|
198
|
+
const applicationEl = applicationElements[0];
|
|
199
|
+
if (!applicationEl) {
|
|
200
|
+
unsupportedConstructs.push("Manifest has no <application> element.");
|
|
201
|
+
}
|
|
202
|
+
if (applicationElements.length > 1) {
|
|
203
|
+
parseWarnings.push(`Manifest declares ${applicationElements.length} <application> elements; only the first was used.`);
|
|
204
|
+
}
|
|
205
|
+
const application = applicationEl
|
|
206
|
+
? {
|
|
207
|
+
name: androidAttr(applicationEl, "name"),
|
|
208
|
+
label: androidAttr(applicationEl, "label"),
|
|
209
|
+
iconRef: androidAttr(applicationEl, "icon"),
|
|
210
|
+
enabled: parseBooleanAttr(applicationEl, "enabled", parseWarnings, "<application>").value,
|
|
211
|
+
permission: androidAttr(applicationEl, "permission"),
|
|
212
|
+
allowBackup: parseBooleanAttr(applicationEl, "allowBackup", parseWarnings, "<application>").value,
|
|
213
|
+
debuggable: parseBooleanAttr(applicationEl, "debuggable", parseWarnings, "<application>").value,
|
|
214
|
+
usesCleartextTraffic: parseBooleanAttr(applicationEl, "usesCleartextTraffic", parseWarnings, "<application>").value,
|
|
215
|
+
networkSecurityConfigRef: androidAttr(applicationEl, "networkSecurityConfig"),
|
|
216
|
+
}
|
|
217
|
+
: {};
|
|
218
|
+
const activities = applicationEl ? findChildren(applicationEl, "activity").map((el) => parseComponent("activity", el, parseWarnings)) : [];
|
|
219
|
+
const activityAliases = applicationEl
|
|
220
|
+
? findChildren(applicationEl, "activity-alias").map((el) => parseComponent("activity-alias", el, parseWarnings))
|
|
221
|
+
: [];
|
|
222
|
+
const services = applicationEl ? findChildren(applicationEl, "service").map((el) => parseComponent("service", el, parseWarnings)) : [];
|
|
223
|
+
const receivers = applicationEl ? findChildren(applicationEl, "receiver").map((el) => parseComponent("receiver", el, parseWarnings)) : [];
|
|
224
|
+
const providers = applicationEl ? findChildren(applicationEl, "provider").map((el) => parseComponent("provider", el, parseWarnings)) : [];
|
|
225
|
+
if (applicationEl) {
|
|
226
|
+
const knownChildNames = new Set(["activity", "activity-alias", "service", "receiver", "provider", "meta-data"]);
|
|
227
|
+
for (const child of applicationEl.children) {
|
|
228
|
+
if (!knownChildNames.has(child.localName)) {
|
|
229
|
+
unsupportedConstructs.push(`Unsupported <application> child element <${child.tagName}> at line ${child.location.line}.`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const deepLinks = [];
|
|
234
|
+
let launcherActivityName;
|
|
235
|
+
for (const component of [...activities, ...activityAliases]) {
|
|
236
|
+
if (component.isLauncherActivity && !launcherActivityName) {
|
|
237
|
+
launcherActivityName = component.name;
|
|
238
|
+
}
|
|
239
|
+
for (const filter of component.intentFilters) {
|
|
240
|
+
if (filter.isDeepLinkCandidate) {
|
|
241
|
+
deepLinks.push(...(filter.dataElements ?? filter.filterData));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
manifestPath,
|
|
247
|
+
packageName,
|
|
248
|
+
application,
|
|
249
|
+
permissions,
|
|
250
|
+
usesFeatures,
|
|
251
|
+
activities,
|
|
252
|
+
activityAliases,
|
|
253
|
+
services,
|
|
254
|
+
receivers,
|
|
255
|
+
providers,
|
|
256
|
+
deepLinks,
|
|
257
|
+
launcherActivityName,
|
|
258
|
+
parseWarnings,
|
|
259
|
+
unsupportedConstructs,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
// Reads and parses a manifest file. `manifestRelativePath` must already be
|
|
263
|
+
// target-relative (as returned by AndroidDetectionResult.manifestPaths); this
|
|
264
|
+
// re-validates containment before reading so a caller-supplied path can never
|
|
265
|
+
// escape targetRoot.
|
|
266
|
+
export function parseAndroidManifestFile(targetRoot, manifestRelativePath) {
|
|
267
|
+
const resolvedRoot = path.resolve(targetRoot);
|
|
268
|
+
let absolutePath;
|
|
269
|
+
try {
|
|
270
|
+
absolutePath = resolveWithinRoot(resolvedRoot, manifestRelativePath);
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
return {
|
|
274
|
+
manifestPath: manifestRelativePath,
|
|
275
|
+
application: {},
|
|
276
|
+
permissions: [],
|
|
277
|
+
usesFeatures: [],
|
|
278
|
+
activities: [],
|
|
279
|
+
activityAliases: [],
|
|
280
|
+
services: [],
|
|
281
|
+
receivers: [],
|
|
282
|
+
providers: [],
|
|
283
|
+
deepLinks: [],
|
|
284
|
+
parseWarnings: [`Manifest path rejected: ${error instanceof Error ? error.message : "path escapes target root"}`],
|
|
285
|
+
unsupportedConstructs: [],
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const normalizedPath = relativeWithinRoot(resolvedRoot, absolutePath);
|
|
289
|
+
let xmlText;
|
|
290
|
+
try {
|
|
291
|
+
xmlText = readFileSync(absolutePath, "utf8");
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
return {
|
|
295
|
+
manifestPath: normalizedPath,
|
|
296
|
+
application: {},
|
|
297
|
+
permissions: [],
|
|
298
|
+
usesFeatures: [],
|
|
299
|
+
activities: [],
|
|
300
|
+
activityAliases: [],
|
|
301
|
+
services: [],
|
|
302
|
+
receivers: [],
|
|
303
|
+
providers: [],
|
|
304
|
+
deepLinks: [],
|
|
305
|
+
parseWarnings: [`Could not read manifest file: ${error instanceof Error ? error.message : "unknown error"}`],
|
|
306
|
+
unsupportedConstructs: [],
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
return parseAndroidManifestSource(xmlText, normalizedPath);
|
|
310
|
+
}
|
|
311
|
+
const KNOWN_SOURCE_SET_KINDS = ["main", "debug", "release", "androidTest", "test"];
|
|
312
|
+
function inferSourceSetKind(manifestPath) {
|
|
313
|
+
const segments = manifestPath.split("/");
|
|
314
|
+
const srcIndex = segments.indexOf("src");
|
|
315
|
+
const sourceSetSegment = srcIndex !== -1 ? segments[srcIndex + 1] : undefined;
|
|
316
|
+
return KNOWN_SOURCE_SET_KINDS.includes(sourceSetSegment ?? "")
|
|
317
|
+
? sourceSetSegment
|
|
318
|
+
: "other";
|
|
319
|
+
}
|
|
320
|
+
function inferModulePath(manifestPath) {
|
|
321
|
+
const srcIndex = manifestPath.indexOf("/src/");
|
|
322
|
+
return srcIndex === -1 ? undefined : manifestPath.slice(0, srcIndex);
|
|
323
|
+
}
|
|
324
|
+
// Parses every manifest path from a Batch 2 AndroidDetectionResult
|
|
325
|
+
// independently. One malformed manifest never prevents the others from being
|
|
326
|
+
// parsed. Does not merge manifests — each entry stands alone, tagged with its
|
|
327
|
+
// inferred module and source-set association for later batches to use.
|
|
328
|
+
export function parseAllAndroidManifests(targetRoot, detection) {
|
|
329
|
+
const moduleManifestMap = new Map();
|
|
330
|
+
for (const module of detection.modules) {
|
|
331
|
+
for (const manifestPath of module.manifestPaths) {
|
|
332
|
+
moduleManifestMap.set(manifestPath, module.path);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return [...detection.manifestPaths].sort((a, b) => a.localeCompare(b)).map((manifestPath) => ({
|
|
336
|
+
manifestPath,
|
|
337
|
+
modulePath: moduleManifestMap.get(manifestPath) ?? inferModulePath(manifestPath),
|
|
338
|
+
sourceSetKind: inferSourceSetKind(manifestPath),
|
|
339
|
+
manifest: parseAndroidManifestFile(targetRoot, manifestPath),
|
|
340
|
+
}));
|
|
341
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// v0.4.0 Batch 1 — AndroidManifest model foundation.
|
|
3
|
+
// v0.4.0 Batch 3 — narrow additive inherited-contract corrections needed by
|
|
4
|
+
// manifest parsing and the initial audits (see agents.txt Batch 3 section
|
|
5
|
+
// 6.7). Every change below only adds new optional fields/enum members or a
|
|
6
|
+
// new optional array — no existing required field, type, or enum value was
|
|
7
|
+
// removed or retyped, so all Batch 1/2 object literals remain valid:
|
|
8
|
+
// - AndroidManifestComponentKind gained "activity-alias" (section 7.6).
|
|
9
|
+
// - AndroidManifestComponent gained optional `exportedRaw` so a malformed
|
|
10
|
+
// android:exported value can be distinguished from "unspecified"
|
|
11
|
+
// (`exported` stays undefined in both cases; `exportedRaw` preserves the
|
|
12
|
+
// literal attribute text when present) — section 7.7's invariant that
|
|
13
|
+
// malformed values must never be normalized to false requires this.
|
|
14
|
+
// - AndroidManifestModel gained optional `activityAliases` (section 7.6).
|
|
15
|
+
// - AndroidIntentFilter gained optional `actions`/`categories`/
|
|
16
|
+
// `dataElements` so multiple sibling <action>/<category>/<data> children
|
|
17
|
+
// can be preserved independently (section 7.8) without discarding the
|
|
18
|
+
// original `filterData` field, which Batch 1 tests and any external
|
|
19
|
+
// consumer may already rely on; the parser keeps populating both.
|
|
20
|
+
// - AndroidIntentFilterData gained optional `location` (section 7.11).
|
|
21
|
+
// - AndroidPermissionDeclaration gained optional `sourceElement` so
|
|
22
|
+
// uses-permission / uses-permission-sdk-23 / uses-permission-sdk-m are
|
|
23
|
+
// not conflated (section 7.4, ANDROID-B3-03).
|
|
24
|
+
//
|
|
25
|
+
// Normalized data contracts consumed by the manifest parser and the
|
|
26
|
+
// permission/component/deep-link auditors added in later batches. This file
|
|
27
|
+
// defines shape only — no XML parsing and no finding generation happen here.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// v0.4.0 Batch 3 — bounded, deterministic, dependency-free XML parser for
|
|
3
|
+
// AndroidManifest.xml.
|
|
4
|
+
//
|
|
5
|
+
// No XML parsing dependency exists in this repository (see package.json —
|
|
6
|
+
// zero runtime dependencies), so this hand-written recursive-descent scanner
|
|
7
|
+
// avoids adding one, per agents.txt Batch 3 section 7.2 ("prefer a small
|
|
8
|
+
// bounded parser implementation ... Do not use brittle global regular
|
|
9
|
+
// expressions as the primary XML parser"). This is a manual character
|
|
10
|
+
// scanner (no regex-based tokenization), tracks line/column for every
|
|
11
|
+
// element and attribute, and resolves XML namespace URIs from `xmlns`/
|
|
12
|
+
// `xmlns:*` declarations rather than assuming any particular prefix — so an
|
|
13
|
+
// `android:` attribute is recognized even if a manifest (unusually)
|
|
14
|
+
// re-prefixes the Android namespace.
|
|
15
|
+
//
|
|
16
|
+
// Deliberately NOT implemented (out of scope for a manifest-only parser):
|
|
17
|
+
// DTD/entity resolution beyond skipping DOCTYPE, full XML 1.1 conformance,
|
|
18
|
+
// processing-instruction data, and CDATA content extraction (CDATA is
|
|
19
|
+
// skipped as opaque text — AndroidManifest.xml has no meaningful use of it).
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
export const ANDROID_NAMESPACE_URI = "http://schemas.android.com/apk/res/android";
|
|
22
|
+
class XmlSyntaxError extends Error {
|
|
23
|
+
location;
|
|
24
|
+
constructor(message, location) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.location = location;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const NAME_START_PATTERN = /[A-Za-z_:]/;
|
|
30
|
+
const NAME_CHAR_PATTERN = /[A-Za-z0-9_.:-]/;
|
|
31
|
+
function isNameStartChar(ch) {
|
|
32
|
+
return ch !== undefined && NAME_START_PATTERN.test(ch);
|
|
33
|
+
}
|
|
34
|
+
function isNameChar(ch) {
|
|
35
|
+
return ch !== undefined && NAME_CHAR_PATTERN.test(ch);
|
|
36
|
+
}
|
|
37
|
+
function isWhitespace(ch) {
|
|
38
|
+
return ch === " " || ch === "\t" || ch === "\n" || ch === "\r";
|
|
39
|
+
}
|
|
40
|
+
class Scanner {
|
|
41
|
+
text;
|
|
42
|
+
pos = 0;
|
|
43
|
+
line = 1;
|
|
44
|
+
column = 1;
|
|
45
|
+
constructor(text) {
|
|
46
|
+
// Strip a UTF-8 BOM if present so it doesn't count as document content.
|
|
47
|
+
this.text = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
48
|
+
}
|
|
49
|
+
loc() {
|
|
50
|
+
return { line: this.line, column: this.column };
|
|
51
|
+
}
|
|
52
|
+
eof() {
|
|
53
|
+
return this.pos >= this.text.length;
|
|
54
|
+
}
|
|
55
|
+
peek(offset = 0) {
|
|
56
|
+
return this.text[this.pos + offset];
|
|
57
|
+
}
|
|
58
|
+
startsWith(literal) {
|
|
59
|
+
return this.text.startsWith(literal, this.pos);
|
|
60
|
+
}
|
|
61
|
+
advance() {
|
|
62
|
+
const ch = this.text[this.pos];
|
|
63
|
+
if (ch === undefined) {
|
|
64
|
+
throw new XmlSyntaxError("Unexpected end of document", this.loc());
|
|
65
|
+
}
|
|
66
|
+
this.pos += 1;
|
|
67
|
+
if (ch === "\n") {
|
|
68
|
+
this.line += 1;
|
|
69
|
+
this.column = 1;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
this.column += 1;
|
|
73
|
+
}
|
|
74
|
+
return ch;
|
|
75
|
+
}
|
|
76
|
+
skipWhitespace() {
|
|
77
|
+
while (!this.eof() && isWhitespace(this.peek()))
|
|
78
|
+
this.advance();
|
|
79
|
+
}
|
|
80
|
+
expect(literal) {
|
|
81
|
+
if (!this.startsWith(literal)) {
|
|
82
|
+
throw new XmlSyntaxError(`Expected "${literal}"`, this.loc());
|
|
83
|
+
}
|
|
84
|
+
for (let i = 0; i < literal.length; i++)
|
|
85
|
+
this.advance();
|
|
86
|
+
}
|
|
87
|
+
skipUntil(literal) {
|
|
88
|
+
while (!this.eof() && !this.startsWith(literal))
|
|
89
|
+
this.advance();
|
|
90
|
+
if (this.eof()) {
|
|
91
|
+
throw new XmlSyntaxError(`Unterminated construct, expected "${literal}"`, this.loc());
|
|
92
|
+
}
|
|
93
|
+
this.expect(literal);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function resolveNamespace(prefix, scope) {
|
|
97
|
+
return scope.get(prefix ?? "");
|
|
98
|
+
}
|
|
99
|
+
function splitQualifiedName(qualifiedName) {
|
|
100
|
+
const colonIndex = qualifiedName.indexOf(":");
|
|
101
|
+
if (colonIndex === -1)
|
|
102
|
+
return { localName: qualifiedName };
|
|
103
|
+
return { prefix: qualifiedName.slice(0, colonIndex), localName: qualifiedName.slice(colonIndex + 1) };
|
|
104
|
+
}
|
|
105
|
+
function readName(scanner) {
|
|
106
|
+
const start = scanner.loc();
|
|
107
|
+
if (!isNameStartChar(scanner.peek())) {
|
|
108
|
+
throw new XmlSyntaxError("Expected a name", start);
|
|
109
|
+
}
|
|
110
|
+
let name = "";
|
|
111
|
+
while (isNameChar(scanner.peek()))
|
|
112
|
+
name += scanner.advance();
|
|
113
|
+
return name;
|
|
114
|
+
}
|
|
115
|
+
function readQuotedValue(scanner) {
|
|
116
|
+
const quote = scanner.peek();
|
|
117
|
+
if (quote !== `"` && quote !== `'`) {
|
|
118
|
+
throw new XmlSyntaxError("Expected a quoted attribute value", scanner.loc());
|
|
119
|
+
}
|
|
120
|
+
scanner.advance();
|
|
121
|
+
let value = "";
|
|
122
|
+
while (!scanner.eof() && scanner.peek() !== quote) {
|
|
123
|
+
value += scanner.advance();
|
|
124
|
+
}
|
|
125
|
+
if (scanner.eof()) {
|
|
126
|
+
throw new XmlSyntaxError("Unterminated attribute value", scanner.loc());
|
|
127
|
+
}
|
|
128
|
+
scanner.advance();
|
|
129
|
+
return decodeXmlEntities(value);
|
|
130
|
+
}
|
|
131
|
+
function decodeXmlEntities(raw) {
|
|
132
|
+
return raw
|
|
133
|
+
.replace(/</g, "<")
|
|
134
|
+
.replace(/>/g, ">")
|
|
135
|
+
.replace(/'/g, "'")
|
|
136
|
+
.replace(/"/g, '"')
|
|
137
|
+
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec)))
|
|
138
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
|
|
139
|
+
.replace(/&/g, "&");
|
|
140
|
+
}
|
|
141
|
+
function skipMisc(scanner, warnings) {
|
|
142
|
+
// Skips whitespace, comments, the XML declaration/processing instructions,
|
|
143
|
+
// and DOCTYPE declarations that may appear between/around elements.
|
|
144
|
+
for (;;) {
|
|
145
|
+
scanner.skipWhitespace();
|
|
146
|
+
if (scanner.startsWith("<!--")) {
|
|
147
|
+
scanner.skipUntil("-->");
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (scanner.startsWith("<?")) {
|
|
151
|
+
scanner.skipUntil("?>");
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (scanner.startsWith("<!DOCTYPE") || scanner.startsWith("<!doctype")) {
|
|
155
|
+
warnings.push({ message: "DOCTYPE declaration encountered and skipped (not evaluated)", location: scanner.loc() });
|
|
156
|
+
scanner.skipUntil(">");
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (scanner.startsWith("<![CDATA[")) {
|
|
160
|
+
scanner.skipUntil("]]>");
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function parseAttributes(scanner) {
|
|
167
|
+
const rawAttributes = [];
|
|
168
|
+
for (;;) {
|
|
169
|
+
scanner.skipWhitespace();
|
|
170
|
+
const next = scanner.peek();
|
|
171
|
+
if (next === undefined || next === "/" || next === ">")
|
|
172
|
+
break;
|
|
173
|
+
const location = scanner.loc();
|
|
174
|
+
const rawName = readName(scanner);
|
|
175
|
+
scanner.skipWhitespace();
|
|
176
|
+
scanner.expect("=");
|
|
177
|
+
scanner.skipWhitespace();
|
|
178
|
+
const value = readQuotedValue(scanner);
|
|
179
|
+
rawAttributes.push({ rawName, value, location });
|
|
180
|
+
}
|
|
181
|
+
// Build the namespace scope contributed by this element's own xmlns/xmlns:*
|
|
182
|
+
// attributes so attribute namespace resolution can use it immediately
|
|
183
|
+
// (XML allows an element to both declare and use a namespace prefix).
|
|
184
|
+
const localScope = new Map();
|
|
185
|
+
for (const attr of rawAttributes) {
|
|
186
|
+
if (attr.rawName === "xmlns") {
|
|
187
|
+
localScope.set("", attr.value);
|
|
188
|
+
}
|
|
189
|
+
else if (attr.rawName.startsWith("xmlns:")) {
|
|
190
|
+
localScope.set(attr.rawName.slice("xmlns:".length), attr.value);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { rawAttributes, localScope };
|
|
194
|
+
}
|
|
195
|
+
// Resolves each raw attribute's namespace URI against the combined
|
|
196
|
+
// (ancestor + this element's own) namespace scope. `xmlns`/`xmlns:*`
|
|
197
|
+
// attributes themselves are preserved as regular attributes (some manifest
|
|
198
|
+
// tooling inspects them) but are not namespace-resolved.
|
|
199
|
+
function resolveAttributeNamespaces(rawAttributes, scope, warnings) {
|
|
200
|
+
return rawAttributes.map(({ rawName, value, location }) => {
|
|
201
|
+
const { prefix, localName } = splitQualifiedName(rawName);
|
|
202
|
+
if (rawName === "xmlns" || rawName.startsWith("xmlns:")) {
|
|
203
|
+
return { rawName, localName, value, location };
|
|
204
|
+
}
|
|
205
|
+
if (prefix === undefined) {
|
|
206
|
+
return { rawName, localName, value, location };
|
|
207
|
+
}
|
|
208
|
+
const namespaceUri = resolveNamespace(prefix, scope);
|
|
209
|
+
if (namespaceUri === undefined) {
|
|
210
|
+
warnings.push({ message: `Unresolved namespace prefix "${prefix}" on attribute "${rawName}"`, location });
|
|
211
|
+
}
|
|
212
|
+
return { rawName, prefix, localName, namespaceUri, value, location };
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function parseElement(scanner, parentScope, warnings) {
|
|
216
|
+
const location = scanner.loc();
|
|
217
|
+
scanner.expect("<");
|
|
218
|
+
const tagName = readName(scanner);
|
|
219
|
+
const { rawAttributes, localScope } = parseAttributes(scanner);
|
|
220
|
+
const scope = localScope.size > 0 ? new Map([...parentScope, ...localScope]) : parentScope;
|
|
221
|
+
const attributes = resolveAttributeNamespaces(rawAttributes, scope, warnings);
|
|
222
|
+
const { prefix, localName } = splitQualifiedName(tagName);
|
|
223
|
+
const namespaceUri = prefix !== undefined ? resolveNamespace(prefix, scope) : resolveNamespace(undefined, scope);
|
|
224
|
+
scanner.skipWhitespace();
|
|
225
|
+
if (scanner.startsWith("/>")) {
|
|
226
|
+
scanner.expect("/>");
|
|
227
|
+
return { tagName, prefix, localName, namespaceUri, attributes, children: [], location };
|
|
228
|
+
}
|
|
229
|
+
scanner.expect(">");
|
|
230
|
+
const children = [];
|
|
231
|
+
for (;;) {
|
|
232
|
+
skipTextAndMisc(scanner, warnings);
|
|
233
|
+
if (scanner.startsWith("</")) {
|
|
234
|
+
scanner.expect("</");
|
|
235
|
+
const closingName = readName(scanner);
|
|
236
|
+
scanner.skipWhitespace();
|
|
237
|
+
scanner.expect(">");
|
|
238
|
+
if (closingName !== tagName) {
|
|
239
|
+
throw new XmlSyntaxError(`Mismatched closing tag: expected "</${tagName}>" but found "</${closingName}>"`, location);
|
|
240
|
+
}
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
if (scanner.eof()) {
|
|
244
|
+
throw new XmlSyntaxError(`Unterminated element "<${tagName}>" (missing closing tag)`, location);
|
|
245
|
+
}
|
|
246
|
+
if (scanner.peek() === "<" && isNameStartChar(scanner.peek(1))) {
|
|
247
|
+
children.push(parseElement(scanner, scope, warnings));
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
// Any other content (unexpected `<` not matching a comment/PI/CDATA/
|
|
251
|
+
// element/closing-tag pattern) is treated as malformed input.
|
|
252
|
+
throw new XmlSyntaxError("Unexpected content while looking for a child element or closing tag", scanner.loc());
|
|
253
|
+
}
|
|
254
|
+
return { tagName, prefix, localName, namespaceUri, attributes, children, location };
|
|
255
|
+
}
|
|
256
|
+
function skipTextAndMisc(scanner, warnings) {
|
|
257
|
+
for (;;) {
|
|
258
|
+
if (scanner.startsWith("<!--")) {
|
|
259
|
+
scanner.skipUntil("-->");
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (scanner.startsWith("<![CDATA[")) {
|
|
263
|
+
scanner.skipUntil("]]>");
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (scanner.startsWith("<?")) {
|
|
267
|
+
scanner.skipUntil("?>");
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (scanner.peek() === "<")
|
|
271
|
+
break;
|
|
272
|
+
if (scanner.eof())
|
|
273
|
+
break;
|
|
274
|
+
// Plain text content between elements — not meaningful for manifest
|
|
275
|
+
// structure, so it is discarded rather than stored.
|
|
276
|
+
scanner.advance();
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Parses `xmlText` into a small element tree. Never throws: all failures are
|
|
281
|
+
// reported via the `ok: false` branch with a bounded message and location.
|
|
282
|
+
export function parseXmlDocument(xmlText) {
|
|
283
|
+
const warnings = [];
|
|
284
|
+
try {
|
|
285
|
+
const scanner = new Scanner(xmlText);
|
|
286
|
+
skipMisc(scanner, warnings);
|
|
287
|
+
if (scanner.eof() || scanner.peek() !== "<") {
|
|
288
|
+
return { ok: false, error: { message: "No root element found", location: scanner.loc() }, warnings };
|
|
289
|
+
}
|
|
290
|
+
const root = parseElement(scanner, new Map(), warnings);
|
|
291
|
+
skipMisc(scanner, warnings);
|
|
292
|
+
if (!scanner.eof()) {
|
|
293
|
+
warnings.push({ message: "Unexpected trailing content after the root element was ignored", location: scanner.loc() });
|
|
294
|
+
}
|
|
295
|
+
return { ok: true, root, warnings };
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
if (error instanceof XmlSyntaxError) {
|
|
299
|
+
return { ok: false, error: { message: error.message, location: error.location }, warnings };
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
ok: false,
|
|
303
|
+
error: { message: error instanceof Error ? error.message : "Unknown XML parse error", location: { line: 1, column: 1 } },
|
|
304
|
+
warnings,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
export function findChildren(element, localName, namespaceUri) {
|
|
309
|
+
return element.children.filter((child) => child.localName === localName && (namespaceUri === undefined || child.namespaceUri === namespaceUri));
|
|
310
|
+
}
|
|
311
|
+
export function getAttribute(element, localName, namespaceUri = ANDROID_NAMESPACE_URI) {
|
|
312
|
+
return element.attributes.find((attr) => attr.localName === localName && attr.namespaceUri === namespaceUri);
|
|
313
|
+
}
|