mirror of
https://github.com/morluto/rea
synced 2026-10-26 14:25:49 -04:00
f2fa7f648d
* 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>
1068 lines
44 KiB
TypeScript
1068 lines
44 KiB
TypeScript
import { expect, it, vi } from "vitest";
|
|
import { androidResultSchemas } from "../../../src/domain/android/androidAnalysis.js";
|
|
|
|
import {
|
|
ANDROID_MANIFEST_LIMITS,
|
|
parseAndroidManifestComponents,
|
|
resolveAndroidComponentName,
|
|
} from "../../../src/android/AndroidManifestComponents.js";
|
|
|
|
it.each([
|
|
[".sub.Target", "sub.Target", "partial"],
|
|
["com.example.sub.Target", "sub.Target", "partial"],
|
|
[".sub.Target", ".sub.Target", "complete"],
|
|
[".sub.Target", "com.example.sub.Target", "complete"],
|
|
["com.example.sub.Target", ".sub.Target", "complete"],
|
|
["com.example.sub.Target", "com.example.sub.Target", "complete"],
|
|
[".Target", "Target", "complete"],
|
|
["sub.Target", "sub.Target", "complete"],
|
|
] as const)(
|
|
"keeps alias class identity for %s targeting %s",
|
|
(activity, target, status) => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application><activity android:name="${activity}"/><activity-alias android:name=".Alias" android:targetActivity="${target}" android:exported="false"/></application></manifest>`,
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.status).toBe(status);
|
|
expect(inventory.items.at(-1)?.target_activity).toBe(target);
|
|
const limitations = inventory.limitations.join(" ");
|
|
if (status === "partial")
|
|
expect(limitations).toContain(
|
|
"do not match a preceding activity declaration",
|
|
);
|
|
else expect(limitations).not.toContain("targetActivity value(s)");
|
|
},
|
|
);
|
|
|
|
it("keeps pre-projection package parses independent", () => {
|
|
const older = {
|
|
engine: {
|
|
name: "jadx",
|
|
version: null,
|
|
artifact_sha256: "a".repeat(64),
|
|
source_revision: null,
|
|
worker_count: 1,
|
|
heap_limit_mib: null,
|
|
},
|
|
package_name: "com.example",
|
|
version_name: null,
|
|
version_code: null,
|
|
min_sdk: null,
|
|
target_sdk: "33",
|
|
permissions: [],
|
|
manifest: {
|
|
status: "complete",
|
|
text: '<manifest package="com.example"/>',
|
|
reported_total_bytes: null,
|
|
},
|
|
class_count: 0,
|
|
resource_count: 0,
|
|
signature_verification: "not_performed",
|
|
};
|
|
const first = androidResultSchemas.inspect_android_package.parse(older);
|
|
expect(first.components).toMatchObject({ status: "unavailable", items: [] });
|
|
first.components.limitations.push("local parse mutation");
|
|
try {
|
|
const second = androidResultSchemas.inspect_android_package.parse(older);
|
|
expect(second.components.limitations).not.toContain("local parse mutation");
|
|
} finally {
|
|
first.components.limitations.pop();
|
|
}
|
|
});
|
|
|
|
const MANIFEST = `<?xml version="1.0" encoding="utf-8"?>
|
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.app">
|
|
<application android:label="Example">
|
|
<activity android:name=".MainActivity" android:exported="true">
|
|
<intent-filter>
|
|
<action android:name="android.intent.action.MAIN" />
|
|
<category android:name="android.intent.category.LAUNCHER" />
|
|
</intent-filter>
|
|
</activity>
|
|
<activity android:name="DetailActivity">
|
|
<intent-filter>
|
|
<action android:name="com.example.app.OPEN_DETAIL" />
|
|
</intent-filter>
|
|
</activity>
|
|
<activity android:name="com.example.app.HiddenActivity" android:exported="false">
|
|
<intent-filter>
|
|
<action android:name="com.example.app.HIDDEN" />
|
|
</intent-filter>
|
|
</activity>
|
|
<activity-alias
|
|
android:name=".AliasActivity"
|
|
android:targetActivity=".MainActivity"
|
|
android:exported="true" />
|
|
<service
|
|
android:name=".SyncService"
|
|
android:permission="com.example.app.SYNC"
|
|
android:foregroundServiceType="dataSync" />
|
|
<receiver android:name=".BootReceiver">
|
|
<intent-filter>
|
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
|
</intent-filter>
|
|
</receiver>
|
|
<provider
|
|
android:name=".DataProvider"
|
|
android:authorities="com.example.app.data;com.example.app.backup"
|
|
android:exported="false" />
|
|
<meta-data android:name="ignored" android:value="not-a-component" />
|
|
</application>
|
|
</manifest>`;
|
|
|
|
it.each([".9Activity", ".hyphen-name", ".😀Activity", ".nested.7"])(
|
|
"resolves DEX component name %s and its alias reference",
|
|
(name) => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application><activity android:name="${name}"/><activity-alias android:name=".Alias" android:targetActivity="${name}"/></application></manifest>`,
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.items[0]?.name).toBe(`com.example${name}`);
|
|
expect(inventory.items[1]?.target_activity).toBe(name);
|
|
expect(inventory.status).toBe("complete");
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["0x10", true, true],
|
|
["0X11", false, true],
|
|
["0x1f", false, null],
|
|
] as const)(
|
|
"interprets decoded hexadecimal target SDK %s",
|
|
(sdk, providerDefault, activityDefault) => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application><provider android:name=".Provider"/><activity android:name=".Activity"><intent-filter><action android:name="OPEN"/></intent-filter></activity></application></manifest>`,
|
|
"complete",
|
|
sdk,
|
|
);
|
|
expect(inventory.items.map((item) => item.exported_default)).toEqual([
|
|
providerDefault,
|
|
activityDefault,
|
|
]);
|
|
},
|
|
);
|
|
|
|
it("uses a decoded hexadecimal minimum SDK when target SDK is omitted", () => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><uses-sdk android:minSdkVersion="0x10"/><application><provider android:name=".Provider"/></application></manifest>',
|
|
"complete",
|
|
null,
|
|
"0x10",
|
|
);
|
|
expect(inventory.items[0]?.exported_default).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
"0x",
|
|
"0xgg",
|
|
"1e1",
|
|
"-1",
|
|
"0",
|
|
"2147483648",
|
|
"0x80000000",
|
|
"Preview",
|
|
])("keeps unsupported SDK spelling %s unknown", (sdk) => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application><provider android:name=".Provider"/></application></manifest>',
|
|
"complete",
|
|
sdk,
|
|
);
|
|
expect(inventory.items[0]?.exported_default).toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"application",
|
|
'<application xmlns="urn:vendor"><activity xmlns="" android:name=".Decoy"/></application>',
|
|
[],
|
|
[],
|
|
],
|
|
[
|
|
"component",
|
|
'<application><activity xmlns="urn:vendor" android:name=".Decoy"/></application>',
|
|
[],
|
|
[],
|
|
],
|
|
[
|
|
"filter",
|
|
'<application><activity android:name=".Real"><intent-filter xmlns="urn:vendor"><action android:name="DECOY"/></intent-filter></activity></application>',
|
|
["activity"],
|
|
[],
|
|
],
|
|
[
|
|
"action",
|
|
'<application><activity android:name=".Real"><intent-filter><action xmlns="urn:vendor" android:name="DECOY"/><action android:name="REAL"/></intent-filter></activity></application>',
|
|
["activity"],
|
|
["REAL"],
|
|
],
|
|
[
|
|
"category",
|
|
'<application><activity android:name=".Real"><intent-filter><category xmlns="urn:vendor" android:name="DECOY"/><action android:name="REAL"/></intent-filter></activity></application>',
|
|
["activity"],
|
|
["REAL"],
|
|
],
|
|
] as const)(
|
|
"ignores foreign namespace %s elements",
|
|
(_kind, body, kinds, actions) => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example">${body}</manifest>`,
|
|
"complete",
|
|
"30",
|
|
);
|
|
expect(inventory.status).toBe("complete");
|
|
expect(inventory.items.map((item) => item.kind)).toEqual(kinds);
|
|
expect(inventory.items[0]?.actions ?? []).toEqual(actions);
|
|
expect(inventory.items[0]?.categories ?? []).toEqual([]);
|
|
if (_kind === "filter")
|
|
expect(inventory.items[0]?.intent_filter_count).toBe(0);
|
|
},
|
|
);
|
|
|
|
it("refuses a namespaced manifest root", () => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
'<manifest xmlns="urn:vendor" xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application><activity android:name=".Decoy"/></application></manifest>',
|
|
"complete",
|
|
"30",
|
|
);
|
|
expect(inventory.status).toBe("unavailable");
|
|
expect(inventory.items).toEqual([]);
|
|
});
|
|
|
|
it("does not index foreign activities as alias targets", () => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application><activity xmlns="urn:vendor" android:name=".Target"/><activity-alias android:name=".Alias" android:targetActivity=".Target"/></application></manifest>',
|
|
"complete",
|
|
"30",
|
|
);
|
|
expect(inventory.items.map((item) => item.kind)).toEqual(["activity-alias"]);
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.limitations.join(" ")).toContain(
|
|
"do not match a preceding activity declaration",
|
|
);
|
|
});
|
|
|
|
it("projects declared components with resolved names and export defaults", () => {
|
|
const inventory = parseAndroidManifestComponents(MANIFEST, "complete", "30");
|
|
expect(inventory.status).toBe("complete");
|
|
expect(inventory.limitations).toEqual([]);
|
|
expect(inventory.items.map(({ kind, name }) => [kind, name])).toEqual([
|
|
["activity", "com.example.app.MainActivity"],
|
|
["activity", "com.example.app.DetailActivity"],
|
|
["activity", "com.example.app.HiddenActivity"],
|
|
["activity-alias", "com.example.app.AliasActivity"],
|
|
["service", "com.example.app.SyncService"],
|
|
["receiver", "com.example.app.BootReceiver"],
|
|
["provider", "com.example.app.DataProvider"],
|
|
]);
|
|
expect(inventory.items[0]).toMatchObject({
|
|
declared_name: ".MainActivity",
|
|
exported: true,
|
|
exported_default: true,
|
|
actions: ["android.intent.action.MAIN"],
|
|
categories: ["android.intent.category.LAUNCHER"],
|
|
});
|
|
// Absent android:exported keeps the declaration and the default separate.
|
|
expect(inventory.items[1]).toMatchObject({
|
|
exported: null,
|
|
exported_default: true,
|
|
});
|
|
expect(inventory.items[2]).toMatchObject({
|
|
exported: false,
|
|
exported_default: true,
|
|
});
|
|
expect(inventory.items[3]).toMatchObject({
|
|
target_activity: ".MainActivity",
|
|
});
|
|
expect(inventory.items[4]).toMatchObject({
|
|
permission: "com.example.app.SYNC",
|
|
foreground_service_type: "dataSync",
|
|
actions: [],
|
|
});
|
|
expect(inventory.items[5]).toMatchObject({
|
|
actions: ["android.intent.action.BOOT_COMPLETED"],
|
|
categories: [],
|
|
});
|
|
expect(inventory.items[6]?.authorities).toEqual([
|
|
"com.example.app.data",
|
|
"com.example.app.backup",
|
|
]);
|
|
});
|
|
|
|
it("inherits an application permission when a component omits its own", () => {
|
|
const manifest = MANIFEST.replace(
|
|
'<application android:label="Example">',
|
|
'<application android:label="Example" android:permission="com.example.APP">',
|
|
);
|
|
const inventory = parseAndroidManifestComponents(manifest, "complete", "33");
|
|
expect(inventory.items[0]?.permission).toBe("com.example.APP");
|
|
expect(inventory.items[0]?.permission_source).toBe("application");
|
|
expect(inventory.items[4]?.permission).toBe("com.example.app.SYNC");
|
|
expect(inventory.items[4]?.permission_source).toBe("component");
|
|
});
|
|
|
|
it("preserves exact action, category, and effective permission strings", () => {
|
|
const manifest =
|
|
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application android:permission=" P "><activity android:name=".Open"><intent-filter><action android:name=" OPEN "/><category android:name=" "/></intent-filter></activity><service android:name=".Worker" android:permission=" C "/></application></manifest>';
|
|
const inventory = parseAndroidManifestComponents(manifest, "complete", "33");
|
|
expect(inventory.items.map(({ permission }) => permission)).toEqual([
|
|
" P ",
|
|
" C ",
|
|
]);
|
|
expect(inventory.items[0]).toMatchObject({
|
|
actions: [" OPEN "],
|
|
categories: [" "],
|
|
permission_source: "application",
|
|
});
|
|
expect(inventory.status).toBe("complete");
|
|
|
|
const whitespacePermission = parseAndroidManifestComponents(
|
|
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application android:permission=" "><activity android:name=".Open"/></application></manifest>',
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(whitespacePermission.items[0]).toMatchObject({
|
|
permission: " ",
|
|
permission_source: "application",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
".Bad/Main",
|
|
".Bad//Main",
|
|
"com.example.Bad/Name",
|
|
"com..example.Bad",
|
|
"com.example.Bad;Name",
|
|
"com.example.Bad[Name",
|
|
])(
|
|
"retains malformed component class name %s without resolving it",
|
|
(declaredName) => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application><activity android:name="${declaredName}"/></application></manifest>`,
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.items[0]).toMatchObject({
|
|
declared_name: declaredName,
|
|
name: declaredName,
|
|
});
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.limitations.join(" ")).toContain(
|
|
"do not fit DEX binary class-name syntax",
|
|
);
|
|
},
|
|
);
|
|
|
|
it("accepts Java nested and Unicode class identifiers", () => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application><activity android:name=".Outer$Inner"/><activity android:name=".Δelta"/></application></manifest>',
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.items.map(({ name }) => name)).toEqual([
|
|
"com.example.Outer$Inner",
|
|
"com.example.Δelta",
|
|
]);
|
|
expect(inventory.status).toBe("complete");
|
|
});
|
|
|
|
it("does not inherit application permission through an explicit empty override", () => {
|
|
const manifest =
|
|
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application android:permission="com.example.APP"><activity android:name=".Open" android:permission=""/></application></manifest>';
|
|
const inventory = parseAndroidManifestComponents(manifest, "complete", "33");
|
|
expect(inventory.items[0]).toMatchObject({
|
|
permission: null,
|
|
permission_source: "none",
|
|
});
|
|
});
|
|
|
|
it("marks unprojected provider read and write permission overrides partial", () => {
|
|
const manifest =
|
|
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application><provider android:name=".ReadOnly" android:readPermission="com.example.READ"/><provider android:name=".WriteOnly" android:writePermission="com.example.WRITE"/><provider android:name=".Generic" android:permission="com.example.GENERIC" android:readPermission="com.example.READ"/></application></manifest>';
|
|
const inventory = parseAndroidManifestComponents(manifest, "complete", "33");
|
|
expect(
|
|
inventory.items.map(({ permission, permission_source }) => [
|
|
permission,
|
|
permission_source,
|
|
]),
|
|
).toEqual([
|
|
[null, "none"],
|
|
[null, "none"],
|
|
["com.example.GENERIC", "component"],
|
|
]);
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.limitations.join(" ")).toContain(
|
|
"operation-specific permission overrides are retained",
|
|
);
|
|
});
|
|
|
|
it("does not apply application or target permissions through an activity alias", () => {
|
|
const manifest =
|
|
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application android:permission="com.example.APP"><activity android:name=".Target" android:permission="com.example.TARGET"/><activity-alias android:name=".Alias" android:targetActivity=".Target"/></application></manifest>';
|
|
const inventory = parseAndroidManifestComponents(manifest, "complete", "33");
|
|
expect(inventory.items[0]).toMatchObject({
|
|
permission: "com.example.TARGET",
|
|
permission_source: "component",
|
|
});
|
|
expect(inventory.items[1]).toMatchObject({
|
|
permission: null,
|
|
permission_source: "none",
|
|
});
|
|
});
|
|
|
|
it("keeps symbolic effective permissions unknown without resource resolution", () => {
|
|
const manifest =
|
|
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application android:permission="com.example.APP"><activity android:name=".Resource" android:permission="@string/resource_permission"/><service android:name=".Theme" android:permission="?attr/theme_permission"/><receiver android:name=".Placeholder" android:permission="${manifestPermission}"/><provider android:name=".Inherited"/></application></manifest>';
|
|
const inventory = parseAndroidManifestComponents(manifest, "complete", "33");
|
|
expect(
|
|
inventory.items.map(({ permission, permission_source }) => [
|
|
permission,
|
|
permission_source,
|
|
]),
|
|
).toEqual([
|
|
[null, "unknown"],
|
|
[null, "unknown"],
|
|
[null, "unknown"],
|
|
["com.example.APP", "application"],
|
|
]);
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.limitations.join(" ")).toContain(
|
|
"unresolved resource or manifest-placeholder tokens",
|
|
);
|
|
});
|
|
|
|
it("bounds inherited permission copies across the inventory", () => {
|
|
const permission = "p".repeat(2_050);
|
|
const declarations = Array.from(
|
|
{ length: ANDROID_MANIFEST_LIMITS.components },
|
|
(_, index) => `<receiver android:name=".R${String(index)}"/>`,
|
|
).join("");
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application android:permission="${permission}">${declarations}</application></manifest>`,
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.items[0]?.permission_source).toBe("application");
|
|
expect(inventory.items.at(-1)).toMatchObject({
|
|
permission: null,
|
|
permission_source: "unknown",
|
|
});
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.limitations.join(" ")).toContain("total permission bytes");
|
|
expect(
|
|
inventory.items.reduce(
|
|
(bytes, item) => bytes + Buffer.byteLength(item.permission ?? ""),
|
|
0,
|
|
),
|
|
).toBeLessThanOrEqual(ANDROID_MANIFEST_LIMITS.permissionBytes);
|
|
});
|
|
|
|
it("measures an oversized inherited permission once for the whole inventory", () => {
|
|
const permission = "p".repeat(ANDROID_MANIFEST_LIMITS.permissionBytes + 1);
|
|
const declarations = Array.from(
|
|
{ length: ANDROID_MANIFEST_LIMITS.components },
|
|
(_, index) => `<receiver android:name=".R${String(index)}"/>`,
|
|
).join("");
|
|
const byteLength = vi.spyOn(Buffer, "byteLength");
|
|
const includes = vi.spyOn(String.prototype, "includes");
|
|
try {
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application android:permission="${permission}">${declarations}</application></manifest>`,
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(
|
|
byteLength.mock.calls.filter(([value]) => value === permission),
|
|
).toHaveLength(1);
|
|
expect(
|
|
byteLength.mock.calls.filter(([value]) => value === "com.example"),
|
|
).toHaveLength(1);
|
|
// Unresolved-token classification checks three markers once at the
|
|
// application owner, not once for every inherited component copy.
|
|
expect(
|
|
includes.mock.contexts.filter((value) => value === permission),
|
|
).toHaveLength(3);
|
|
expect(inventory.items).toHaveLength(ANDROID_MANIFEST_LIMITS.components);
|
|
expect(
|
|
inventory.items.every((item) => item.permission_source === "unknown"),
|
|
).toBe(true);
|
|
expect(inventory.status).toBe("partial");
|
|
} finally {
|
|
includes.mockRestore();
|
|
byteLength.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("retains forbidden controls and empty component names", () => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application><activity android:name="	.Main	"/><service android:name=""/><receiver android:name=".Valid"/></application></manifest>',
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(
|
|
inventory.items.map(({ declared_name, name }) => [declared_name, name]),
|
|
).toEqual([
|
|
["\t.Main\t", "\t.Main\t"],
|
|
["", ""],
|
|
[".Valid", "com.example.Valid"],
|
|
]);
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.limitations.join(" ")).toContain(
|
|
"DEX binary class-name syntax",
|
|
);
|
|
});
|
|
|
|
it("applies the provider export default from the target SDK", () => {
|
|
// API 16 and below default providers to exported; API 17 and up do not.
|
|
const legacy = parseAndroidManifestComponents(MANIFEST, "complete", "16");
|
|
expect(legacy.items[6]).toMatchObject({
|
|
kind: "provider",
|
|
exported: false,
|
|
exported_default: true,
|
|
});
|
|
const modern = parseAndroidManifestComponents(MANIFEST, "complete", "33");
|
|
expect(modern.items[6]?.exported_default).toBe(false);
|
|
// A reported but unrecognized SDK stays unknown; omission has a legacy default.
|
|
const unknown = parseAndroidManifestComponents(
|
|
MANIFEST,
|
|
"complete",
|
|
"Preview",
|
|
);
|
|
expect(unknown.items[6]?.exported_default).toBeNull();
|
|
expect(unknown.limitations.join(" ")).toContain("target SDK is unknown");
|
|
});
|
|
|
|
it("leaves target-31-or-later filtered components without an inferred export default", () => {
|
|
const manifest =
|
|
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application><activity android:name=".Filtered"><intent-filter><action android:name="example.OPEN"/></intent-filter></activity></application></manifest>';
|
|
const modern = parseAndroidManifestComponents(manifest, "complete", "31");
|
|
expect(modern.items[0]).toMatchObject({
|
|
exported: null,
|
|
exported_default: null,
|
|
});
|
|
expect(modern.limitations.join(" ")).toContain("requires an explicit value");
|
|
expect(
|
|
parseAndroidManifestComponents(manifest, "complete", "30").items[0],
|
|
).toMatchObject({ exported_default: true });
|
|
expect(
|
|
parseAndroidManifestComponents(manifest, "complete", "Preview").items[0],
|
|
).toMatchObject({ exported_default: null });
|
|
});
|
|
|
|
it("ignores same-local-name attributes from other namespaces", () => {
|
|
const manifest = [
|
|
'<?xml version="1.0" encoding="utf-8"?>',
|
|
'<manifest xmlns:android="http://schemas.android.com/apk/res/android"',
|
|
' xmlns:vendor="http://example.com/vendor"',
|
|
' package="com.example.app">',
|
|
" <application>",
|
|
' <activity vendor:name=".Decoy" android:name=".Real" vendor:exported="true" android:exported="false" />',
|
|
" </application>",
|
|
"</manifest>",
|
|
].join("\n");
|
|
const inventory = parseAndroidManifestComponents(manifest, "complete", "33");
|
|
expect(inventory.items).toHaveLength(1);
|
|
expect(inventory.items[0]).toMatchObject({
|
|
declared_name: ".Real",
|
|
name: "com.example.app.Real",
|
|
exported: false,
|
|
exported_default: false,
|
|
});
|
|
});
|
|
|
|
it("resolves relative names against the manifest package", () => {
|
|
expect(resolveAndroidComponentName("com.example.app", ".Main")).toBe(
|
|
"com.example.app.Main",
|
|
);
|
|
expect(resolveAndroidComponentName("com.example.app", "Main")).toBe(
|
|
"com.example.app.Main",
|
|
);
|
|
expect(resolveAndroidComponentName("com.example.app", "org.other.Main")).toBe(
|
|
"org.other.Main",
|
|
);
|
|
expect(resolveAndroidComponentName(null, ".Main")).toBe(".Main");
|
|
});
|
|
|
|
it("keeps relative names unresolved when the manifest package is absent", () => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
'<manifest xmlns:android="http://schemas.android.com/apk/res/android"><application><activity android:name=".Relative"/><activity android:name="Absolute.Name"/></application></manifest>',
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.items.map(({ name }) => name)).toEqual([
|
|
".Relative",
|
|
"Absolute.Name",
|
|
]);
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.limitations.join(" ")).toContain(
|
|
"manifest package is absent",
|
|
);
|
|
});
|
|
|
|
it("does not trim malformed package names into resolved component identities", () => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package=" com.example "><application><activity android:name=".Main"/></application></manifest>',
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.items[0]).toMatchObject({
|
|
declared_name: ".Main",
|
|
name: ".Main",
|
|
});
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.limitations.join(" ")).toContain(
|
|
"package has surrounding whitespace",
|
|
);
|
|
});
|
|
|
|
it("does not resolve package-relative names against malformed package syntax", () => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com..example"><application><activity android:name=".Foo"/><service android:name="Worker"/><receiver android:name="org.other.Valid"/></application></manifest>',
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(
|
|
inventory.items.map(({ declared_name, name }) => [declared_name, name]),
|
|
).toEqual([
|
|
[".Foo", ".Foo"],
|
|
["Worker", "Worker"],
|
|
["org.other.Valid", "org.other.Valid"],
|
|
]);
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.limitations.join(" ")).toContain(
|
|
"does not fit the supported Java package-name syntax",
|
|
);
|
|
expect(inventory.limitations.join(" ")).toContain(
|
|
"package-relative component name(s) remain unresolved",
|
|
);
|
|
});
|
|
|
|
it("preserves exact activity-alias targets and marks malformed references unknown", () => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application><activity android:name=".Target"/><activity android:name="Bare"/><activity-alias android:name=".Whitespace" android:targetActivity=" .Target "/><activity-alias android:name=".Empty" android:targetActivity=""/><activity-alias android:name=".Valid" android:targetActivity=".Target"/><activity-alias android:name=".Qualified" android:targetActivity="com.example.Target"/><activity-alias android:name=".Unqualified" android:targetActivity=".Bare"/><activity-alias android:name=".Missing"/><activity-alias android:name=".Later" android:targetActivity=".DeclaredLater"/><activity android:name=".DeclaredLater"/></application></manifest>',
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.items.map(({ target_activity }) => target_activity)).toEqual(
|
|
[
|
|
null,
|
|
null,
|
|
" .Target ",
|
|
"",
|
|
".Target",
|
|
"com.example.Target",
|
|
".Bare",
|
|
null,
|
|
".DeclaredLater",
|
|
null,
|
|
],
|
|
);
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.limitations.join(" ")).toContain(
|
|
"do not match a preceding activity declaration",
|
|
);
|
|
});
|
|
|
|
it("preserves unresolved aliases without expanding an unsupported huge package", () => {
|
|
const packageName = "p".repeat(900 * 1024);
|
|
const aliases = Array.from(
|
|
{ length: 511 },
|
|
(_, index) =>
|
|
`<activity-alias android:name=".Alias${String(index)}" android:targetActivity=".Target"/>`,
|
|
).join("");
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="${packageName}"><application><activity android:name=".Target"/>${aliases}</application></manifest>`,
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.items[0]?.name).toBe(".Target");
|
|
expect(
|
|
inventory.items.every(({ name }) => !name.startsWith(packageName)),
|
|
).toBe(true);
|
|
expect(inventory.items[1]).toMatchObject({
|
|
declared_name: ".Alias0",
|
|
target_activity: ".Target",
|
|
});
|
|
expect(inventory.items.at(-1)).toMatchObject({
|
|
declared_name: ".Alias510",
|
|
target_activity: ".Target",
|
|
});
|
|
expect(inventory.items).toHaveLength(ANDROID_MANIFEST_LIMITS.components);
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.limitations.join(" ")).toContain("targetActivity value(s)");
|
|
});
|
|
|
|
it("does not call resource and manifest placeholder names resolved identities", () => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application><activity android:name="@string/component_class"/><service android:name="${applicationId}.Worker"/></application></manifest>',
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.items.map(({ name }) => name)).toEqual([
|
|
"@string/component_class",
|
|
"${applicationId}.Worker",
|
|
]);
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.limitations.join(" ")).toContain(
|
|
"unresolved resource or manifest placeholder",
|
|
);
|
|
const placeholderPackage = parseAndroidManifestComponents(
|
|
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="${applicationId}"><application><activity android:name=".Main"/></application></manifest>',
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(placeholderPackage.items[0]?.name).toBe(".Main");
|
|
expect(placeholderPackage.status).toBe("partial");
|
|
expect(placeholderPackage.limitations.join(" ")).toContain(
|
|
"package is absent or contains",
|
|
);
|
|
});
|
|
|
|
it("does not establish a component inventory from truncated manifest text", () => {
|
|
const inventory = parseAndroidManifestComponents(MANIFEST, "partial");
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.items).toEqual([]);
|
|
expect(inventory.limitations[0]).toContain("truncated");
|
|
});
|
|
|
|
it("reports unavailable inventory for malformed or unexpected manifests", () => {
|
|
expect(
|
|
parseAndroidManifestComponents("<manifest><application>", "complete"),
|
|
).toMatchObject({ status: "unavailable", items: [] });
|
|
expect(
|
|
parseAndroidManifestComponents(
|
|
'<?xml version="1.0" encoding="utf-8"?><resources />',
|
|
"complete",
|
|
),
|
|
).toMatchObject({ status: "unavailable", items: [] });
|
|
expect(
|
|
parseAndroidManifestComponents(
|
|
'<?xml version="1.0" encoding="utf-8"?><!DOCTYPE manifest><manifest />',
|
|
"complete",
|
|
),
|
|
).toMatchObject({ status: "unavailable", items: [] });
|
|
});
|
|
|
|
it("bounds the component inventory and says so", () => {
|
|
const repeated = Array.from(
|
|
{ length: ANDROID_MANIFEST_LIMITS.components + 1 },
|
|
(_, index) => `<activity android:name=".Activity${String(index)}" />`,
|
|
).join("");
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<?xml version="1.0" encoding="utf-8"?><manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.app"><application>${repeated}</application></manifest>`,
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.items).toHaveLength(ANDROID_MANIFEST_LIMITS.components);
|
|
expect(inventory.limitations.join(" ")).toContain(
|
|
String(ANDROID_MANIFEST_LIMITS.components),
|
|
);
|
|
});
|
|
|
|
it("shares one intent-value budget across actions and categories", () => {
|
|
const values = Array.from(
|
|
{ length: 80 },
|
|
(_, index) =>
|
|
`<intent-filter><action android:name="a${String(index)}" /><category android:name="c${String(index)}" /></intent-filter>`,
|
|
).join("");
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<?xml version="1.0" encoding="utf-8"?><manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.app"><application><activity android:name=".Many">${values}</activity></application></manifest>`,
|
|
"complete",
|
|
"33",
|
|
);
|
|
const first = inventory.items[0];
|
|
expect(first).toBeDefined();
|
|
expect((first?.actions.length ?? 0) + (first?.categories.length ?? 0)).toBe(
|
|
ANDROID_MANIFEST_LIMITS.intentValues,
|
|
);
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.limitations.join(" ")).toContain("values per component");
|
|
});
|
|
|
|
it("bounds provider authorities", () => {
|
|
const authorities = Array.from(
|
|
{ length: ANDROID_MANIFEST_LIMITS.authorities + 5 },
|
|
(_, index) => `auth${String(index)}`,
|
|
).join(";");
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<?xml version="1.0" encoding="utf-8"?><manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.app"><application><provider android:name=".Many" android:authorities="${authorities}" /></application></manifest>`,
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.items[0]?.authorities).toHaveLength(
|
|
ANDROID_MANIFEST_LIMITS.authorities,
|
|
);
|
|
expect(inventory.status).toBe("partial");
|
|
});
|
|
|
|
it("bounds total authority bytes across providers", () => {
|
|
const first = "a".repeat(900_000);
|
|
const second = "b".repeat(200_000);
|
|
const manifest = `<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application><provider android:name=".One" android:authorities="${first}"/><provider android:name=".Two" android:authorities="${second}"/></application></manifest>`;
|
|
const inventory = parseAndroidManifestComponents(manifest, "complete", "33");
|
|
expect(inventory.items[0]?.authorities?.[0]?.length).toBe(900_000);
|
|
expect(inventory.items[1]?.authorities).toEqual([]);
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.limitations.join(" ")).toContain("total authority bytes");
|
|
});
|
|
|
|
it("preserves malformed provider authority segments and marks them unknown", () => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application><provider android:name=".Provider" android:authorities=" com.example.p ;;com.example.q;"/></application></manifest>',
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.items[0]?.authorities).toEqual([
|
|
" com.example.p ",
|
|
"",
|
|
"com.example.q",
|
|
"",
|
|
]);
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.limitations.join(" ")).toContain(
|
|
"provider authority segment(s) are empty or have surrounding whitespace",
|
|
);
|
|
});
|
|
|
|
it("leaves later names declared when the resolved-name budget is spent", () => {
|
|
const hugeName = "A".repeat(64 * 1024);
|
|
const names = Array.from(
|
|
{ length: 20 },
|
|
(_, index) => `<activity android:name=".${hugeName}${String(index)}" />`,
|
|
).join("");
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<?xml version="1.0" encoding="utf-8"?><manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application>${names}</application></manifest>`,
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.items.length).toBeGreaterThan(1);
|
|
expect(inventory.items[0]?.name).toBe(`com.example.${hugeName}0`);
|
|
const unresolved = inventory.items.filter(({ name }) => name.startsWith("."));
|
|
expect(unresolved.length).toBeGreaterThan(0);
|
|
expect(unresolved.every(({ name }) => name.startsWith(`.${hugeName}`))).toBe(
|
|
true,
|
|
);
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.limitations.join(" ")).toContain(
|
|
"resolved-name byte budget",
|
|
);
|
|
expect(inventory.limitations.join(" ")).not.toContain(
|
|
"component inventory stops at",
|
|
);
|
|
});
|
|
|
|
it("does not let the first component bypass the resolved-name byte budget", () => {
|
|
const hugeName =
|
|
"." + "A".repeat(ANDROID_MANIFEST_LIMITS.resolvedNameBytes + 1);
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application><activity android:name="${hugeName}"/></application></manifest>`,
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.items[0]?.name).toBe(hugeName);
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.limitations.join(" ")).toContain(
|
|
"resolved-name byte budget",
|
|
);
|
|
});
|
|
|
|
it("measures resolved names in UTF-8 bytes before concatenating them", () => {
|
|
const multibyteName = "." + "界".repeat(400_000);
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application><activity android:name="${multibyteName}"/></application></manifest>`,
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.items[0]?.name).toBe(multibyteName);
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.limitations.join(" ")).toContain(
|
|
"resolved-name byte budget",
|
|
);
|
|
});
|
|
|
|
it("marks the inventory partial when nameless declarations are dropped", () => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<?xml version="1.0" encoding="utf-8"?><manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.app"><application><activity /><activity android:name=".Real" /></application></manifest>`,
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.items).toHaveLength(1);
|
|
expect(inventory.limitations.join(" ")).toContain(
|
|
"1 component declaration(s) without android:name",
|
|
);
|
|
expect(inventory.limitations.join(" ")).not.toContain(
|
|
"component inventory stops at",
|
|
);
|
|
});
|
|
|
|
it("does not warn about providers that declare android:exported", () => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<?xml version="1.0" encoding="utf-8"?><manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.app"><uses-sdk android:targetSdkVersion="Preview"/><application><provider android:name=".Declared" android:exported="false" /><provider android:name=".Omitted" /></application></manifest>`,
|
|
"complete",
|
|
"Preview",
|
|
);
|
|
expect(inventory.limitations.join(" ")).toContain("1 provider(s) omit");
|
|
expect(inventory.limitations.join(" ")).not.toContain("2 provider(s) omit");
|
|
});
|
|
|
|
it("counts unnamed declarations against the component bound", () => {
|
|
const unnamed = Array.from(
|
|
{ length: ANDROID_MANIFEST_LIMITS.components + 1 },
|
|
() => "<activity />",
|
|
).join("");
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<?xml version="1.0" encoding="utf-8"?><manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.app"><application>${unnamed}</application></manifest>`,
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.items).toHaveLength(0);
|
|
expect(inventory.limitations.join(" ")).toContain(
|
|
`${String(ANDROID_MANIFEST_LIMITS.components)} component`,
|
|
);
|
|
});
|
|
|
|
it("distinguishes nonliteral android:exported values from omission", () => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<?xml version="1.0" encoding="utf-8"?><manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.app"><uses-sdk android:targetSdkVersion="Preview"/><application><provider android:name=".Resource" android:exported="@bool/exported" /><provider android:name=".Omitted" /><provider android:name=".Empty" android:exported="" /><activity android:name=".True" android:exported="True"><intent-filter><action android:name="com.example.app.OPEN" /></intent-filter></activity></application></manifest>`,
|
|
"complete",
|
|
"Preview",
|
|
);
|
|
const resource = inventory.items.find(({ name }) =>
|
|
name.endsWith(".Resource"),
|
|
);
|
|
expect(resource).toMatchObject({ exported: null, exported_default: null });
|
|
const empty = inventory.items.find(({ name }) => name.endsWith(".Empty"));
|
|
expect(empty).toMatchObject({ exported: null, exported_default: null });
|
|
const trueCase = inventory.items.find(({ name }) => name.endsWith(".True"));
|
|
expect(trueCase).toMatchObject({ exported: null, exported_default: null });
|
|
const joined = inventory.limitations.join(" ");
|
|
expect(joined).toContain("1 provider(s) omit");
|
|
expect(joined).toContain(
|
|
"3 component declaration(s) set android:exported to a value other than the literals true or false",
|
|
);
|
|
expect(inventory.status).toBe("complete");
|
|
});
|
|
|
|
it("keeps a known SDK default from governing a nonliteral exported value", () => {
|
|
const legacy = parseAndroidManifestComponents(
|
|
`<?xml version="1.0" encoding="utf-8"?><manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.app"><application><provider android:name=".Resource" android:exported="@bool/exported" /></application></manifest>`,
|
|
"complete",
|
|
"16",
|
|
);
|
|
expect(legacy.items[0]).toMatchObject({
|
|
exported: null,
|
|
exported_default: null,
|
|
});
|
|
const modern = parseAndroidManifestComponents(
|
|
`<?xml version="1.0" encoding="utf-8"?><manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.app"><application><activity android:name=".Resource" android:exported="@bool/exported"><intent-filter><action android:name="com.example.app.OPEN" /></intent-filter></activity></application></manifest>`,
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(modern.items[0]).toMatchObject({
|
|
exported: null,
|
|
exported_default: null,
|
|
});
|
|
expect(modern.limitations.join(" ")).toContain(
|
|
"1 component declaration(s) set android:exported",
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
"com._hidden",
|
|
"com.$hidden",
|
|
"com.Δelta",
|
|
"com.9bad",
|
|
"Single",
|
|
"com..example",
|
|
".com.example",
|
|
"com.example.",
|
|
"a." + "b".repeat(222),
|
|
])(
|
|
"preserves relative class identities under an unsupported Android package %s",
|
|
(packageName) => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="${packageName}"><application><activity android:name=".Main"/><activity-alias android:name=".Alias" android:targetActivity=".Main" android:exported="false"/></application></manifest>`,
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.items.map(({ name }) => name)).toEqual([
|
|
".Main",
|
|
".Alias",
|
|
]);
|
|
expect(inventory.items[1]?.target_activity).toBe(".Main");
|
|
},
|
|
);
|
|
|
|
it.each(["android", "com.Example_9", "a." + "b".repeat(221)])(
|
|
"qualifies names under the supported Android package %s",
|
|
(packageName) => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="${packageName}"><application><activity android:name=".Main"/><activity-alias android:name=".Alias" android:targetActivity=".Main" android:exported="false"/></application></manifest>`,
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.status).toBe("complete");
|
|
expect(inventory.items.map(({ name }) => name)).toEqual([
|
|
`${packageName}.Main`,
|
|
`${packageName}.Alias`,
|
|
]);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
[".Hidden ", ".Hidden ", "com.example.Hidden "],
|
|
[" Hidden", ". Hidden", "com.example. Hidden"],
|
|
[". Hidden ", "com.example. Hidden ", "com.example. Hidden "],
|
|
[" ", ". ", "com.example. "],
|
|
[" other.Hidden ", " other.Hidden ", " other.Hidden "],
|
|
])(
|
|
"preserves DEX boundary spaces in %j and alias %j",
|
|
(declared, target, expected) => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application><activity android:name="${declared}"/><activity-alias android:name=".Alias" android:targetActivity="${target}" android:exported="false"/></application></manifest>`,
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.status).toBe("complete");
|
|
expect(inventory.items[0]).toMatchObject({
|
|
declared_name: declared,
|
|
name: expected,
|
|
});
|
|
expect(inventory.items[1]?.target_activity).toBe(target);
|
|
},
|
|
);
|
|
|
|
it("keeps distinct DEX spellings distinct in alias matching", () => {
|
|
const inventory = parseAndroidManifestComponents(
|
|
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application><activity android:name=".Hidden"/><activity-alias android:name=".Alias" android:targetActivity=".Hidden " android:exported="false"/></application></manifest>',
|
|
"complete",
|
|
"33",
|
|
);
|
|
expect(inventory.status).toBe("partial");
|
|
expect(inventory.items[1]?.target_activity).toBe(".Hidden ");
|
|
});
|
|
|
|
it.each([
|
|
null,
|
|
"",
|
|
" ",
|
|
" location ",
|
|
"location|dataSync",
|
|
"@string/service_type",
|
|
])("preserves the declared foreground service type %j", (declared) => {
|
|
const attribute =
|
|
declared === null ? "" : ` android:foregroundServiceType="${declared}"`;
|
|
const inventory = parseAndroidManifestComponents(
|
|
`<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example"><application><service android:name=".Service"${attribute}/></application></manifest>`,
|
|
"complete",
|
|
"30",
|
|
);
|
|
expect(inventory.items[0]?.foreground_service_type).toBe(declared);
|
|
expect(inventory.status).toBe("complete");
|
|
});
|