mirror of
https://github.com/morluto/rea.git
synced 2026-11-03 13:26:10 +00:00
* feat(android): project a structured manifest component inventory * fix(android): derive provider export defaults from the target SDK and match namespaced attributes * fix(android): bound resolved names and authorities in the component inventory * fix(android): separate nonliteral exported values from omission * fix(android): bound manifest component projection * fix(android): preserve permission override semantics * fix(android): retain unresolved package identities * test(android): cover multibyte manifest name budget * fix(android): measure inherited permission once * fix(android): preserve unknown symbolic permissions * fix(android): preserve manifest component uncertainty * fix(android): report unsupported manifest facets * fix(android): preserve manifest identities * fix(android): preserve alias target references * fix(android): validate alias targets and authorities * fix(android): bound alias target expansion * fix(android): index alias target spellings * fix(android): preserve alias identities and isolate legacy defaults * fix(android): keep legacy component defaults typed as unavailable * docs(android): record manifest projection and real JADX costs * fix(android): preserve SDK defaults and manifest namespaces * docs(android): verify SDK and namespace ownership corrections * fix(android): accept DEX class names and hexadecimal SDK levels * docs(android): record DEX and SDK follow-up verification * fix(android): validate manifest package identity separately * docs(android): record final package-identity verification * fix(android): preserve exact DEX component and alias names * docs(android): record exact-name integration verification * fix(android): retain declared foreground service type text * docs(android): record final declared-value validation --------- Co-authored-by: BaaRai <baarai@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
702 lines
27 KiB
TypeScript
702 lines
27 KiB
TypeScript
import type { Element, Node } from "@xmldom/xmldom";
|
|
|
|
import { parseXmlDocument } from "../domain/xmlDocument.js";
|
|
|
|
/** Component kinds a decoded AndroidManifest can declare under <application>. */
|
|
export const ANDROID_COMPONENT_KINDS = [
|
|
"activity",
|
|
"activity-alias",
|
|
"service",
|
|
"receiver",
|
|
"provider",
|
|
] as const;
|
|
export type AndroidComponentKind = (typeof ANDROID_COMPONENT_KINDS)[number];
|
|
|
|
/** Bounds keep a hostile manifest from exhausting the inspection. */
|
|
export const ANDROID_MANIFEST_LIMITS = {
|
|
components: 512,
|
|
intentFilters: 32,
|
|
intentValues: 64,
|
|
authorities: 64,
|
|
/** Total bytes of resolved component names, bounding package amplification. */
|
|
resolvedNameBytes: 1024 * 1024,
|
|
authorityBytes: 1024 * 1024,
|
|
/** Total copied permission-value bytes, including inherited defaults. */
|
|
permissionBytes: 1024 * 1024,
|
|
} as const;
|
|
|
|
/** The attribute namespace every component facet uses. */
|
|
const ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android";
|
|
|
|
export interface AndroidManifestComponent {
|
|
readonly kind: AndroidComponentKind;
|
|
/** Class name as written in the manifest. */
|
|
readonly declared_name: string;
|
|
/**
|
|
* Fully qualified name with the manifest package applied, or the declared
|
|
* name unchanged when it cannot be resolved or its syntax is not
|
|
* established.
|
|
*/
|
|
readonly name: string;
|
|
/**
|
|
* Declared android:exported, or null when the attribute is absent or set
|
|
* to a value other than the literals true or false, whose effective state
|
|
* the parser cannot resolve.
|
|
*/
|
|
readonly exported: boolean | null;
|
|
/**
|
|
* Android's default export state when android:exported is absent: the
|
|
* intent-filter rule for activities, services, receivers and aliases, and
|
|
* the target-SDK rule for providers. Null when the provider rule cannot be
|
|
* evaluated because the target SDK is unknown, or when the attribute is
|
|
* present but not a literal boolean, since the default does not govern a
|
|
* declared value.
|
|
*/
|
|
readonly exported_default: boolean | null;
|
|
readonly permission: string | null;
|
|
/** Source of the effective permission projection; unknown when its copy is capped. */
|
|
readonly permission_source: "component" | "application" | "none" | "unknown";
|
|
readonly intent_filter_count: number;
|
|
readonly actions: readonly string[];
|
|
readonly categories: readonly string[];
|
|
/** Declared android:foregroundServiceType on services; null otherwise. */
|
|
readonly foreground_service_type: string | null;
|
|
/** Exact declared provider authority segments, split on ";"; malformed segments remain raw. */
|
|
readonly authorities: readonly string[] | null;
|
|
/** Exact declared android:targetActivity on activity-alias; malformed references remain raw and make the inventory partial. */
|
|
readonly target_activity: string | null;
|
|
}
|
|
|
|
export interface AndroidManifestComponentInventory {
|
|
readonly status: "complete" | "partial" | "unavailable";
|
|
readonly items: readonly AndroidManifestComponent[];
|
|
readonly limitations: readonly string[];
|
|
}
|
|
|
|
const PARTIAL_LIMITATION =
|
|
"The decoded manifest text is truncated, so its component inventory is not established; the retained manifest is partial.";
|
|
|
|
const unavailable = (
|
|
limitation: string,
|
|
): AndroidManifestComponentInventory => ({
|
|
status: "unavailable",
|
|
items: [],
|
|
limitations: [limitation],
|
|
});
|
|
|
|
const childNodes = (element: Element): readonly Node[] =>
|
|
Array.from(element.childNodes);
|
|
|
|
/** Manifest tags are unnamespaced; Android's namespace qualifies attributes. */
|
|
const isManifestElement = (node: Node | null): node is Element =>
|
|
node !== null &&
|
|
node.nodeType === 1 &&
|
|
(node as Element).namespaceURI === null;
|
|
|
|
/** Read an unnamespaced attribute, the shape manifest `package` uses. */
|
|
const plainAttribute = (element: Element, name: string): string | null => {
|
|
const value = element.getAttribute(name);
|
|
return value === "" ? null : value;
|
|
};
|
|
|
|
/**
|
|
* Read an android-namespaced attribute. XML prefixes are aliases, so a
|
|
* same-local-name attribute from another namespace is never a match.
|
|
*/
|
|
const androidAttribute = (
|
|
element: Element,
|
|
localName: string,
|
|
): string | null => {
|
|
const attributes = element.attributes;
|
|
for (let index = 0; index < attributes.length; index += 1) {
|
|
const node = attributes.item(index);
|
|
if (node === null || node.localName !== localName) continue;
|
|
if (node.namespaceURI === ANDROID_NAMESPACE) return node.nodeValue;
|
|
// Without namespace information (an undeclared prefix), the conventional
|
|
// android: prefix is the only remaining signal.
|
|
if (node.namespaceURI === null && node.prefix === "android")
|
|
return node.nodeValue;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
/** Android application-ID grammar, with the platform package exception. */
|
|
const hasSupportedAndroidPackageNameSyntax = (name: string): boolean =>
|
|
name === "android" ||
|
|
(name.length <= 223 &&
|
|
/^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/u.test(name));
|
|
|
|
// DEX SimpleName permits digits at the start, '-', supplemental code points,
|
|
// and (since format 040) spaces. A decoded manifest does not qualify DEX
|
|
// version or class existence; accept the documented character union here.
|
|
const dexSimpleNameCharacter =
|
|
/^[A-Za-z0-9$_\- \u00a0-\u1fff\u2000-\u200a\u2010-\u2027\u202f-\ud7ff\ue000-\uffef\u{10000}-\u{10ffff}]$/u;
|
|
|
|
const hasSupportedDexBinaryClassNameSyntax = (name: string): boolean => {
|
|
let segmentStart = true;
|
|
let first = true;
|
|
for (const character of name) {
|
|
if (first && character === ".") {
|
|
first = false;
|
|
continue;
|
|
}
|
|
first = false;
|
|
if (character === ".") {
|
|
if (segmentStart) return false;
|
|
segmentStart = true;
|
|
} else {
|
|
if (!dexSimpleNameCharacter.test(character)) return false;
|
|
segmentStart = false;
|
|
}
|
|
}
|
|
return !segmentStart;
|
|
};
|
|
|
|
const containsUnresolvedToken = (value: string): boolean =>
|
|
value.includes("@") || value.includes("?") || value.includes("${");
|
|
|
|
/**
|
|
* Android resolves a component name against the manifest package: a leading
|
|
* dot or a name without any dot is relative to the package, while a dotted
|
|
* name is already fully qualified.
|
|
*/
|
|
export const resolveAndroidComponentName = (
|
|
packageName: string | null,
|
|
declared: string,
|
|
): string => {
|
|
if (declared.startsWith(".")) return `${packageName ?? ""}${declared}`;
|
|
if (!declared.includes(".") && packageName !== null)
|
|
return `${packageName}.${declared}`;
|
|
return declared;
|
|
};
|
|
|
|
const isComponentKind = (tag: string): tag is AndroidComponentKind =>
|
|
(ANDROID_COMPONENT_KINDS as readonly string[]).includes(tag);
|
|
|
|
const androidTargetSdkLevel = (targetSdk: string | null): number | null => {
|
|
// JADX prints TYPE_INT_HEX as 0x..., while TYPE_INT_DEC stays decimal.
|
|
// Both originate in an Android signed 32-bit resource integer.
|
|
if (targetSdk === null || !/^(?:[0-9]+|0x[0-9a-f]+)$/iu.test(targetSdk))
|
|
return null;
|
|
const level = Number(targetSdk);
|
|
return Number.isInteger(level) && level >= 1 && level <= 0x7fffffff
|
|
? level
|
|
: null;
|
|
};
|
|
|
|
/**
|
|
* Collect unique intent-filter actions and categories. The value budget is
|
|
* shared across both arrays, matching the stated per-component bound.
|
|
*/
|
|
const intentValues = (
|
|
filters: readonly Element[],
|
|
): { actions: string[]; categories: string[]; truncated: boolean } => {
|
|
const actions: string[] = [];
|
|
const categories: string[] = [];
|
|
let truncated = filters.length > ANDROID_MANIFEST_LIMITS.intentFilters;
|
|
let budget = ANDROID_MANIFEST_LIMITS.intentValues;
|
|
for (const filter of filters.slice(0, ANDROID_MANIFEST_LIMITS.intentFilters))
|
|
for (const member of childNodes(filter)) {
|
|
if (!isManifestElement(member)) continue;
|
|
const element = member;
|
|
if (element.nodeName !== "action" && element.nodeName !== "category")
|
|
continue;
|
|
const value = androidAttribute(element, "name");
|
|
if (value === null) continue;
|
|
const target = element.nodeName === "action" ? actions : categories;
|
|
if (target.includes(value)) continue;
|
|
if (budget <= 0) {
|
|
truncated = true;
|
|
continue;
|
|
}
|
|
budget -= 1;
|
|
target.push(value);
|
|
}
|
|
return { actions, categories, truncated };
|
|
};
|
|
|
|
/** Split a provider authorities declaration within its own bound. */
|
|
const providerAuthorities = (
|
|
declared: string,
|
|
byteLimit: number,
|
|
): {
|
|
authorities: string[];
|
|
truncated: boolean;
|
|
bytes: number;
|
|
malformed: number;
|
|
} => {
|
|
const authorities: string[] = [];
|
|
let truncated = false;
|
|
let bytes = 0;
|
|
let malformed = 0;
|
|
let start = 0;
|
|
while (start <= declared.length) {
|
|
const separator = declared.indexOf(";", start);
|
|
const end = separator < 0 ? declared.length : separator;
|
|
const value = declared.slice(start, end);
|
|
const valueBytes = Buffer.byteLength(value);
|
|
if (
|
|
authorities.length >= ANDROID_MANIFEST_LIMITS.authorities ||
|
|
valueBytes > byteLimit - bytes
|
|
) {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
if (value.length === 0 || value !== value.trim()) malformed += 1;
|
|
authorities.push(value);
|
|
bytes += valueBytes;
|
|
if (separator < 0) break;
|
|
start = separator + 1;
|
|
}
|
|
return { authorities, truncated, bytes, malformed };
|
|
};
|
|
|
|
/**
|
|
* Parse the decoded AndroidManifest XML text into a bounded, deterministic
|
|
* component inventory. The projection is static: it reports declarations and
|
|
* Android's documented export defaults, never merged-manifest or runtime
|
|
* state.
|
|
*/
|
|
export const parseAndroidManifestComponents = (
|
|
manifestText: string,
|
|
coverage: "complete" | "partial",
|
|
declaredTargetSdk: string | null = null,
|
|
minimumSdk: string | null = null,
|
|
): AndroidManifestComponentInventory => {
|
|
if (coverage === "partial")
|
|
return {
|
|
status: "partial",
|
|
items: [],
|
|
limitations: [PARTIAL_LIMITATION],
|
|
};
|
|
const parsed = parseXmlDocument(manifestText, {
|
|
doctype: "reject",
|
|
lineEndings: "preserve",
|
|
});
|
|
if (!parsed.ok)
|
|
return unavailable(
|
|
`The decoded manifest is not well-formed XML (${parsed.error.reason}), so its component inventory is unavailable.`,
|
|
);
|
|
const root = parsed.value.documentElement;
|
|
if (!isManifestElement(root) || root.nodeName !== "manifest")
|
|
return unavailable(
|
|
"The decoded manifest root is not <manifest>, so its component inventory is unavailable.",
|
|
);
|
|
const rawPackageName = plainAttribute(root, "package");
|
|
const packageName = rawPackageName?.trim() || null;
|
|
const packageHasInvalidWhitespace =
|
|
rawPackageName !== null && rawPackageName !== rawPackageName.trim();
|
|
const packageHasUnresolvedToken =
|
|
packageName !== null && containsUnresolvedToken(packageName);
|
|
const packageHasUnsupportedPackageSyntax =
|
|
packageName !== null &&
|
|
!packageHasUnresolvedToken &&
|
|
!hasSupportedAndroidPackageNameSyntax(packageName);
|
|
const packageNameBytes =
|
|
packageName === null ? 0 : Buffer.byteLength(packageName);
|
|
const limitations: string[] = [];
|
|
const items: AndroidManifestComponent[] = [];
|
|
let truncated = false;
|
|
let applicationSeen = false;
|
|
let unnamed = 0;
|
|
let providerDefaultsUnknown = 0;
|
|
let nonliteralExported = 0;
|
|
let unresolvedNames = 0;
|
|
let unresolvedPackages = 0;
|
|
let unresolvedTargetActivities = 0;
|
|
let unresolvedTokens = 0;
|
|
let invalidNames = 0;
|
|
let malformedAuthoritySegments = 0;
|
|
let providerPermissionOverridesUnknown = 0;
|
|
let invalidExported = 0;
|
|
let unknownFilteredExportDefaults = 0;
|
|
let capacityTruncated = false;
|
|
let permissionBudgetLimited = false;
|
|
let unresolvedPermissionTokens = 0;
|
|
let nameBudget = ANDROID_MANIFEST_LIMITS.resolvedNameBytes;
|
|
let authorityBudget = ANDROID_MANIFEST_LIMITS.authorityBytes;
|
|
let permissionBudget = ANDROID_MANIFEST_LIMITS.permissionBytes;
|
|
const children = childNodes(root);
|
|
let targetSdkDeclared = false;
|
|
let minimumSdkDeclared = false;
|
|
for (const child of children) {
|
|
if (!isManifestElement(child) || child.nodeName !== "uses-sdk") continue;
|
|
// Match the bridge's declaration owner: the last uses-sdk supplies values.
|
|
targetSdkDeclared = androidAttribute(child, "targetSdkVersion") !== null;
|
|
minimumSdkDeclared = androidAttribute(child, "minSdkVersion") !== null;
|
|
}
|
|
// Empty protocol values can mean omission or an empty declared attribute.
|
|
// XML presence disambiguates them; an explicit unknown never becomes API 1.
|
|
let effectiveTargetSdk = declaredTargetSdk;
|
|
if (effectiveTargetSdk === null && !targetSdkDeclared) {
|
|
effectiveTargetSdk = minimumSdk;
|
|
if (effectiveTargetSdk === null && !minimumSdkDeclared)
|
|
effectiveTargetSdk = "1";
|
|
}
|
|
const targetSdkLevel = androidTargetSdkLevel(effectiveTargetSdk);
|
|
// Parse the effective SDK once even when hundreds of providers share it.
|
|
const providerDefault = targetSdkLevel === null ? null : targetSdkLevel <= 16;
|
|
for (const node of children) {
|
|
if (!isManifestElement(node)) continue;
|
|
const element = node;
|
|
if (element.nodeName !== "application") continue;
|
|
applicationSeen = true;
|
|
const precedingActivityTargetNames = new Set<string>();
|
|
const declaredApplicationPermission = androidAttribute(
|
|
element,
|
|
"permission",
|
|
);
|
|
const applicationPermission =
|
|
declaredApplicationPermission === null ||
|
|
declaredApplicationPermission.length === 0
|
|
? null
|
|
: declaredApplicationPermission;
|
|
const applicationPermissionHasUnresolvedToken =
|
|
applicationPermission !== null &&
|
|
containsUnresolvedToken(applicationPermission);
|
|
const applicationPermissionBytes =
|
|
applicationPermission === null
|
|
? null
|
|
: Buffer.byteLength(applicationPermission);
|
|
for (const child of childNodes(element)) {
|
|
if (!isManifestElement(child)) continue;
|
|
const component = child;
|
|
if (!isComponentKind(component.nodeName)) continue;
|
|
// Every declaration counts against the bound, named or not, so an
|
|
// unnamed flood cannot exceed it while still reporting complete.
|
|
if (items.length + unnamed >= ANDROID_MANIFEST_LIMITS.components) {
|
|
truncated = true;
|
|
capacityTruncated = true;
|
|
break;
|
|
}
|
|
const declared = androidAttribute(component, "name");
|
|
if (declared === null) {
|
|
unnamed += 1;
|
|
continue;
|
|
}
|
|
const unresolvedName = containsUnresolvedToken(declared);
|
|
const filters = childNodes(component).filter(
|
|
(entry): entry is Element =>
|
|
isManifestElement(entry) && entry.nodeName === "intent-filter",
|
|
);
|
|
const collected = intentValues(filters);
|
|
if (collected.truncated) {
|
|
truncated = true;
|
|
capacityTruncated = true;
|
|
}
|
|
// A huge manifest package would be copied into every resolved name;
|
|
// once the byte budget is spent, later names keep their declared form.
|
|
const invalidDeclaredName =
|
|
declared.length === 0 ||
|
|
(!unresolvedName && !hasSupportedDexBinaryClassNameSyntax(declared));
|
|
const needsPackage = declared.startsWith(".") || !declared.includes(".");
|
|
const packageKnown =
|
|
!invalidDeclaredName &&
|
|
!unresolvedName &&
|
|
(!needsPackage ||
|
|
(packageName !== null &&
|
|
!packageHasInvalidWhitespace &&
|
|
!packageHasUnresolvedToken &&
|
|
!packageHasUnsupportedPackageSyntax));
|
|
const declaredBytes = Buffer.byteLength(declared);
|
|
const resolvedBytes =
|
|
needsPackage && packageKnown
|
|
? packageNameBytes +
|
|
(declared.startsWith(".") ? 0 : 1) +
|
|
declaredBytes
|
|
: declaredBytes;
|
|
const nameFits = packageKnown && resolvedBytes <= nameBudget;
|
|
const name = nameFits
|
|
? resolveAndroidComponentName(packageName, declared)
|
|
: declared;
|
|
if (invalidDeclaredName) invalidNames += 1;
|
|
if (!invalidDeclaredName) {
|
|
if (unresolvedName) unresolvedTokens += 1;
|
|
else if (!packageKnown) unresolvedPackages += 1;
|
|
else if (!nameFits) unresolvedNames += 1;
|
|
else nameBudget -= resolvedBytes;
|
|
}
|
|
if (component.nodeName === "activity" && nameFits) {
|
|
precedingActivityTargetNames.add(name);
|
|
precedingActivityTargetNames.add(declared);
|
|
if (
|
|
packageName !== null &&
|
|
!packageHasInvalidWhitespace &&
|
|
!packageHasUnresolvedToken &&
|
|
!packageHasUnsupportedPackageSyntax &&
|
|
name.length > packageName.length &&
|
|
name.startsWith(packageName) &&
|
|
name[packageName.length] === "."
|
|
) {
|
|
const relativeName = name.slice(packageName.length);
|
|
precedingActivityTargetNames.add(relativeName);
|
|
const bareName = relativeName.slice(1);
|
|
if (!bareName.includes("."))
|
|
precedingActivityTargetNames.add(bareName);
|
|
}
|
|
}
|
|
let authorities: readonly string[] | null = null;
|
|
if (component.nodeName === "provider") {
|
|
if (
|
|
androidAttribute(component, "readPermission") !== null ||
|
|
androidAttribute(component, "writePermission") !== null
|
|
)
|
|
providerPermissionOverridesUnknown += 1;
|
|
const declaredAuthorities = androidAttribute(component, "authorities");
|
|
if (declaredAuthorities !== null) {
|
|
const split = providerAuthorities(
|
|
declaredAuthorities,
|
|
authorityBudget,
|
|
);
|
|
malformedAuthoritySegments += split.malformed;
|
|
if (split.truncated) {
|
|
truncated = true;
|
|
capacityTruncated = true;
|
|
}
|
|
authorityBudget -= split.bytes;
|
|
if (authorityBudget < 0) authorityBudget = 0;
|
|
authorities = split.authorities;
|
|
}
|
|
}
|
|
const declaredPermission = androidAttribute(component, "permission");
|
|
const permissionValue =
|
|
declaredPermission === null
|
|
? component.nodeName === "activity-alias"
|
|
? null
|
|
: applicationPermission
|
|
: declaredPermission.length === 0
|
|
? null
|
|
: declaredPermission;
|
|
const permissionSource =
|
|
permissionValue === null
|
|
? "none"
|
|
: declaredPermission === null
|
|
? "application"
|
|
: "component";
|
|
let permission: string | null = permissionValue;
|
|
let effectivePermissionSource:
|
|
| "component"
|
|
| "application"
|
|
| "none"
|
|
| "unknown" = permissionSource;
|
|
if (permissionValue !== null) {
|
|
const permissionHasUnresolvedToken =
|
|
declaredPermission === null
|
|
? applicationPermissionHasUnresolvedToken
|
|
: containsUnresolvedToken(permissionValue);
|
|
if (permissionHasUnresolvedToken) {
|
|
permission = null;
|
|
effectivePermissionSource = "unknown";
|
|
unresolvedPermissionTokens += 1;
|
|
} else {
|
|
// Inherited application permission is the same string for every
|
|
// component. Measure it once at its owning boundary rather than
|
|
// rescanning a potentially large decoded value per inherited copy.
|
|
const permissionBytes =
|
|
declaredPermission === null
|
|
? (applicationPermissionBytes ?? 0)
|
|
: Buffer.byteLength(permissionValue);
|
|
if (permissionBytes > permissionBudget) {
|
|
permission = null;
|
|
effectivePermissionSource = "unknown";
|
|
truncated = true;
|
|
capacityTruncated = true;
|
|
permissionBudgetLimited = true;
|
|
} else permissionBudget -= permissionBytes;
|
|
}
|
|
}
|
|
const declaredExported = androidAttribute(component, "exported");
|
|
const targetActivity =
|
|
component.nodeName === "activity-alias"
|
|
? androidAttribute(component, "targetActivity")
|
|
: null;
|
|
if (component.nodeName === "activity-alias") {
|
|
if (targetActivity === null) {
|
|
unresolvedTargetActivities += 1;
|
|
} else {
|
|
const targetNeedsPackage =
|
|
targetActivity.startsWith(".") || !targetActivity.includes(".");
|
|
const targetUnresolved = containsUnresolvedToken(targetActivity);
|
|
const targetInvalid =
|
|
targetActivity.length === 0 ||
|
|
(!targetUnresolved &&
|
|
!hasSupportedDexBinaryClassNameSyntax(targetActivity));
|
|
const targetPackageKnown =
|
|
packageName !== null &&
|
|
!packageHasInvalidWhitespace &&
|
|
!packageHasUnresolvedToken &&
|
|
!packageHasUnsupportedPackageSyntax;
|
|
const targetResolved =
|
|
!targetInvalid &&
|
|
!targetUnresolved &&
|
|
(!targetNeedsPackage || targetPackageKnown);
|
|
if (
|
|
!targetResolved ||
|
|
!precedingActivityTargetNames.has(targetActivity)
|
|
)
|
|
unresolvedTargetActivities += 1;
|
|
}
|
|
}
|
|
const exportedLiteral =
|
|
declaredExported === "true"
|
|
? true
|
|
: declaredExported === "false"
|
|
? false
|
|
: null;
|
|
const exportedNonliteral =
|
|
declaredExported !== null && exportedLiteral === null;
|
|
const explicitExportRequired =
|
|
declaredExported === null &&
|
|
targetSdkLevel !== null &&
|
|
targetSdkLevel >= 31 &&
|
|
filters.length > 0 &&
|
|
(component.nodeName === "activity" ||
|
|
component.nodeName === "activity-alias" ||
|
|
component.nodeName === "service" ||
|
|
component.nodeName === "receiver");
|
|
const filteredExportUnknown =
|
|
declaredExported === null &&
|
|
targetSdkLevel === null &&
|
|
filters.length > 0 &&
|
|
(component.nodeName === "activity" ||
|
|
component.nodeName === "activity-alias" ||
|
|
component.nodeName === "service" ||
|
|
component.nodeName === "receiver");
|
|
// The default only matters when android:exported is absent.
|
|
if (
|
|
component.nodeName === "provider" &&
|
|
providerDefault === null &&
|
|
declaredExported === null
|
|
)
|
|
providerDefaultsUnknown += 1;
|
|
if (exportedNonliteral) nonliteralExported += 1;
|
|
if (explicitExportRequired) invalidExported += 1;
|
|
if (filteredExportUnknown) unknownFilteredExportDefaults += 1;
|
|
items.push({
|
|
kind: component.nodeName,
|
|
declared_name: declared,
|
|
name,
|
|
exported: exportedLiteral,
|
|
exported_default:
|
|
exportedNonliteral || explicitExportRequired || filteredExportUnknown
|
|
? null
|
|
: component.nodeName === "provider"
|
|
? providerDefault
|
|
: filters.length > 0,
|
|
permission,
|
|
permission_source: effectivePermissionSource,
|
|
intent_filter_count: filters.length,
|
|
actions: collected.actions,
|
|
categories: collected.categories,
|
|
foreground_service_type:
|
|
component.nodeName === "service"
|
|
? androidAttribute(component, "foregroundServiceType")
|
|
: null,
|
|
authorities,
|
|
target_activity: targetActivity,
|
|
});
|
|
}
|
|
}
|
|
if (!applicationSeen)
|
|
limitations.push(
|
|
"The manifest declares no <application> element, so no components were projected.",
|
|
);
|
|
// Dropped declarations mean the inventory is not the whole manifest.
|
|
if (unnamed > 0) {
|
|
limitations.push(
|
|
`${String(unnamed)} component declaration(s) without android:name are omitted from the inventory.`,
|
|
);
|
|
truncated = true;
|
|
}
|
|
if (invalidNames > 0) {
|
|
limitations.push(
|
|
`${String(invalidNames)} component name value(s) are empty or do not fit DEX binary class-name syntax; the raw declaration is retained without resolving it as an identity.`,
|
|
);
|
|
truncated = true;
|
|
}
|
|
if (packageHasInvalidWhitespace) {
|
|
limitations.push(
|
|
"The manifest package has surrounding whitespace; relative component names were not resolved against it.",
|
|
);
|
|
truncated = true;
|
|
}
|
|
if (packageHasUnsupportedPackageSyntax) {
|
|
limitations.push(
|
|
"The manifest package does not fit the supported Java package-name syntax; package-relative component names were not resolved against it.",
|
|
);
|
|
truncated = true;
|
|
}
|
|
if (providerPermissionOverridesUnknown > 0) {
|
|
limitations.push(
|
|
`${String(providerPermissionOverridesUnknown)} provider(s) declare android:readPermission or android:writePermission; operation-specific permission overrides are retained in the manifest text but are not projected.`,
|
|
);
|
|
truncated = true;
|
|
}
|
|
if (malformedAuthoritySegments > 0) {
|
|
limitations.push(
|
|
`${String(malformedAuthoritySegments)} provider authority segment(s) are empty or have surrounding whitespace; exact segments are retained and their registration is not established.`,
|
|
);
|
|
truncated = true;
|
|
}
|
|
if (providerDefaultsUnknown > 0)
|
|
limitations.push(
|
|
`${String(providerDefaultsUnknown)} provider(s) omit android:exported and the target SDK is unknown, so Android's export default for them is not established.`,
|
|
);
|
|
if (nonliteralExported > 0)
|
|
limitations.push(
|
|
`${String(nonliteralExported)} component declaration(s) set android:exported to a value other than the literals true or false (for example a resource reference), so their effective export state is unknown and the Android default does not apply.`,
|
|
);
|
|
if (unresolvedPackages > 0) {
|
|
limitations.push(
|
|
`${String(unresolvedPackages)} package-relative component name(s) remain unresolved because the manifest package is absent or contains a resource/placeholder token, or its syntax is unsupported; the declared name is retained.`,
|
|
);
|
|
truncated = true;
|
|
}
|
|
if (unresolvedTargetActivities > 0) {
|
|
limitations.push(
|
|
`${String(unresolvedTargetActivities)} activity-alias targetActivity value(s) are empty, malformed, unresolved against the manifest package, or do not match a preceding activity declaration; each exact declared value is retained.`,
|
|
);
|
|
truncated = true;
|
|
}
|
|
if (unresolvedTokens > 0) {
|
|
limitations.push(
|
|
`${String(unresolvedTokens)} component name(s) contain an unresolved resource or manifest placeholder token; the declared name is retained without claiming a resolved identity.`,
|
|
);
|
|
truncated = true;
|
|
}
|
|
if (permissionBudgetLimited)
|
|
limitations.push(
|
|
`Projected component permission values are capped at ${String(ANDROID_MANIFEST_LIMITS.permissionBytes)} total bytes; omitted copies have permission_source "unknown".`,
|
|
);
|
|
if (unresolvedPermissionTokens > 0) {
|
|
limitations.push(
|
|
`${String(unresolvedPermissionTokens)} permission value(s) contain unresolved resource or manifest-placeholder tokens; their effective values are unknown.`,
|
|
);
|
|
truncated = true;
|
|
}
|
|
if (invalidExported > 0) {
|
|
limitations.push(
|
|
`${String(invalidExported)} component(s) with intent filters target API 31 or later and omit android:exported; Android requires an explicit value, so the inventory does not infer a default.`,
|
|
);
|
|
}
|
|
if (unknownFilteredExportDefaults > 0)
|
|
limitations.push(
|
|
`${String(unknownFilteredExportDefaults)} filtered component(s) omit android:exported and the target SDK is unknown, so the applicable export rule is not established.`,
|
|
);
|
|
if (unresolvedNames > 0) {
|
|
limitations.push(
|
|
`${String(unresolvedNames)} component name(s) keep their declared form because the resolved-name byte budget (${String(ANDROID_MANIFEST_LIMITS.resolvedNameBytes)}) was exhausted.`,
|
|
);
|
|
truncated = true;
|
|
}
|
|
if (capacityTruncated)
|
|
limitations.push(
|
|
`The component inventory reached a capacity limit: ${String(ANDROID_MANIFEST_LIMITS.components)} declarations, ${String(ANDROID_MANIFEST_LIMITS.intentFilters)} filters or ${String(ANDROID_MANIFEST_LIMITS.intentValues)} values per component, ${String(ANDROID_MANIFEST_LIMITS.authorities)} authorities per provider, ${String(ANDROID_MANIFEST_LIMITS.authorityBytes)} total authority bytes, or ${String(ANDROID_MANIFEST_LIMITS.permissionBytes)} total permission bytes.`,
|
|
);
|
|
return {
|
|
status: truncated ? "partial" : "complete",
|
|
items,
|
|
limitations,
|
|
};
|
|
};
|