deeplink-parity 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hosung Kang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # deeplink-parity
2
+
3
+ Checks that what your app **declares** about deep links matches what is actually **hosted** — on both iOS and Android, from one command.
4
+
5
+ ```bash
6
+ npx deeplink-parity .
7
+ ```
8
+
9
+ ```
10
+ deeplink-parity · 3 domain(s) checked
11
+
12
+ ERROR links.example.com
13
+ AASA responded with 404
14
+ Universal Links fall through to the browser
15
+ https://links.example.com/.well-known/apple-app-site-association
16
+
17
+ WARN promo.example.com
18
+ Declared on iOS but not on Android
19
+ The same link opens the app on iOS and the browser on Android
20
+
21
+ 1 error, 1 warn, 0 info
22
+ ```
23
+
24
+ <br>
25
+
26
+ ## Why
27
+
28
+ Deep links fail **silently**. There is no crash, no error log, no red test. A user taps a link, the browser opens instead of your app, and they shrug and move on. Nobody finds out for months.
29
+
30
+ And the configuration is split across places with **different owners**:
31
+
32
+ | | Owner |
33
+ |---|---|
34
+ | `*.entitlements` | iOS developer |
35
+ | `AndroidManifest.xml` | Android developer |
36
+ | `apple-app-site-association`, `assetlinks.json` | web / infra team, separate repo, separate deploy |
37
+ | SHA256 signing fingerprint | release manager / Play Console |
38
+
39
+ Each side is individually correct. Breakage happens **at the seams**, and no repo's CI covers a seam that spans two repos and a live domain.
40
+
41
+ Existing validators check one hosted file at a time. None of them start from your app's own configuration, and none of them compare the two platforms — so a domain that works on iOS and quietly fails on Android goes unnoticed.
42
+
43
+ <br>
44
+
45
+ ## What it checks
46
+
47
+ No configuration file. Domains are discovered from your app, then the matching well-known files are fetched from those domains.
48
+
49
+ **Errors** — the link is broken today
50
+
51
+ - The AASA file is unreachable, redirects (iOS does not follow redirects), or does not parse
52
+ - The AASA file does not list your `TeamID.bundleID`
53
+ - `assetlinks.json` is unreachable, does not parse, or has no `handle_all_urls` statement
54
+ - `assetlinks.json` does not list your `applicationId`
55
+ - Your signing fingerprint is missing from `assetlinks.json` (with `--sha256`)
56
+
57
+ **Warnings** — one platform works and the other does not
58
+
59
+ - **A domain is declared on iOS but not Android, or the reverse**
60
+ - An `https` intent-filter has no `autoVerify`
61
+ - A manifest host reference could not be resolved
62
+
63
+ **Info**
64
+
65
+ - The declared path sets differ between platforms
66
+ - The AASA file declares no paths or components
67
+
68
+ <br>
69
+
70
+ ## Usage
71
+
72
+ ```bash
73
+ npx deeplink-parity [path] [options]
74
+
75
+ --sha256 <fingerprint> Android signing fingerprint to look for in assetlinks.json
76
+ --well-known <dir> Read well-known files from <dir>/<domain>/ instead of the network
77
+ --json Machine-readable output
78
+ ```
79
+
80
+ Exits `1` when there is at least one error, so it drops into CI unchanged.
81
+
82
+ ### Run it on a schedule — this is the point
83
+
84
+ Your deep-link configuration changes a few times a year. The things that break it do not live in your repo at all:
85
+
86
+ - the web team's deploy puts a redirect in front of `/.well-known/`
87
+ - a domain expires or its DNS moves
88
+ - the signing key is rotated, or the app moves to Play App Signing
89
+ - an attribution vendor changes their hosting
90
+
91
+ A pull-request check never sees any of this. **A daily scheduled run does.** See [`examples/scheduled.yml`](examples/scheduled.yml).
92
+
93
+ ### Validate before deploying
94
+
95
+ `--well-known <dir>` reads the files from disk instead of the network, so the web team can check staged files before they ship — and CI can run with no egress.
96
+
97
+ ```bash
98
+ npx deeplink-parity . --well-known ./staging/well-known
99
+ ```
100
+
101
+ <br>
102
+
103
+ ## Hardened against real projects
104
+
105
+ Synthetic fixtures agree with whatever the author assumed. Real apps do not — so this was
106
+ run against several open-source iOS and Android apps (Wikipedia, Mastodon, Bitwarden,
107
+ DuckDuckGo), which turned up three bugs that fixtures never would have:
108
+
109
+ - `applinks:*.example.com` is a valid wildcard declaration, and there is no such host to fetch
110
+ - `myapp://callback` carries a host too, but a custom scheme is not an App Link
111
+ - an entitlements file belongs to one target, so the app must not inherit the widget's bundle id
112
+
113
+ Each is now a regression test. A browser's `http`/`https` filters with no host are correctly
114
+ read as browser registration rather than deep links.
115
+
116
+ <br>
117
+
118
+ ## What it does not do
119
+
120
+ - **Deferred deep links are out of scope.** Install-then-open attribution is decided at runtime by your attribution SDK's servers. No static check can confirm it, and reporting a pass would be worse than saying nothing.
121
+ - **It does not prove path equivalence.** iOS and Android use different path-matching engines. Differing path sets are reported as `info` with both sides shown, for a human to judge — not asserted as a bug.
122
+ - **It does not run your app.** Route handling inside the app is not inspected.
123
+
124
+ <br>
125
+
126
+ ## Development
127
+
128
+ ```bash
129
+ npm install
130
+ npm test # runs fully offline against fixtures/
131
+ npm run typecheck
132
+ ```
133
+
134
+ `fixtures/` holds synthetic iOS and Android projects covering literal hosts, chained
135
+ `@string` references resolved through gradle `resValue` and `.properties`, flavor-specific
136
+ values, and unresolvable references.
137
+
138
+ <br>
139
+
140
+ ## License
141
+
142
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from 'node:path';
3
+ import { localSource, networkSource } from './fetch/wellKnown.js';
4
+ import { exitCodeFor, printReport } from './report/console.js';
5
+ import { run } from './run.js';
6
+ const USAGE = `deeplink-parity — check that what your app declares about deep links
7
+ matches what is actually hosted, across iOS and Android.
8
+
9
+ Usage
10
+ deeplink-parity [path] [options]
11
+
12
+ Options
13
+ --sha256 <fingerprint> Android signing fingerprint to look for in assetlinks.json
14
+ --well-known <dir> Read well-known files from <dir>/<domain>/ instead of the network
15
+ --json Machine-readable output
16
+ -h, --help Show this message
17
+ `;
18
+ function parseArgs(argv) {
19
+ const args = argv.slice(2);
20
+ let root = '.';
21
+ let json = false;
22
+ let help = false;
23
+ let sha256;
24
+ let wellKnown;
25
+ for (let i = 0; i < args.length; i++) {
26
+ const arg = args[i];
27
+ if (arg === '--json')
28
+ json = true;
29
+ else if (arg === '-h' || arg === '--help')
30
+ help = true;
31
+ else if (arg === '--sha256')
32
+ sha256 = args[++i];
33
+ else if (arg === '--well-known')
34
+ wellKnown = args[++i];
35
+ else if (!arg.startsWith('-'))
36
+ root = arg;
37
+ }
38
+ return { root: resolve(root), json, help, sha256, wellKnown };
39
+ }
40
+ async function main() {
41
+ const { root, json, help, sha256, wellKnown } = parseArgs(process.argv);
42
+ if (help) {
43
+ console.log(USAGE);
44
+ return;
45
+ }
46
+ const source = wellKnown ? localSource(resolve(wellKnown)) : networkSource();
47
+ const result = await run({ root, source, sha256 });
48
+ if (result.iosApps.length === 0 && result.androidApps.length === 0) {
49
+ console.error(`No app configuration declaring deep links was found in ${root}`);
50
+ console.error('Expected a .entitlements file with applinks:, or an AndroidManifest.xml with intent-filters.');
51
+ process.exit(2);
52
+ }
53
+ if (json) {
54
+ console.log(JSON.stringify({
55
+ ios: result.iosApps.map((a) => ({
56
+ entitlements: a.entitlementsPath,
57
+ teamId: a.teamId,
58
+ bundleId: a.bundleId,
59
+ domains: a.domains,
60
+ })),
61
+ android: result.androidApps.map((a) => ({
62
+ manifest: a.manifestPath,
63
+ packageIds: a.packageIds,
64
+ hosts: a.hosts.map((h) => h.host),
65
+ })),
66
+ findings: result.findings,
67
+ }, null, 2));
68
+ }
69
+ else {
70
+ printReport(result.findings, result.domains);
71
+ }
72
+ process.exit(exitCodeFor(result.findings));
73
+ }
74
+ main().catch((err) => {
75
+ console.error(err);
76
+ process.exit(2);
77
+ });
@@ -0,0 +1,133 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { relative } from 'node:path';
3
+ import { XMLParser } from 'fast-xml-parser';
4
+ import { emptyIndex, indexProperties, indexResValues, indexStrings, resolveRef, } from '../resolve/androidResources.js';
5
+ import { walk } from './walk.js';
6
+ const VIEW_ACTION = 'android.intent.action.VIEW';
7
+ const BROWSABLE = 'android.intent.category.BROWSABLE';
8
+ /** fast-xml-parser hands back a single object when an element occurs once. */
9
+ function asArray(value) {
10
+ if (value === undefined)
11
+ return [];
12
+ return Array.isArray(value) ? value : [value];
13
+ }
14
+ function attr(node, name) {
15
+ if (!node || typeof node !== 'object')
16
+ return undefined;
17
+ const value = node[`@_${name}`];
18
+ return typeof value === 'string' ? value : undefined;
19
+ }
20
+ function collectIntentFilters(node, out) {
21
+ if (!node || typeof node !== 'object')
22
+ return;
23
+ for (const [key, value] of Object.entries(node)) {
24
+ if (key.startsWith('@_'))
25
+ continue;
26
+ if (key === 'intent-filter')
27
+ out.push(...asArray(value));
28
+ else
29
+ for (const child of asArray(value))
30
+ collectIntentFilters(child, out);
31
+ }
32
+ }
33
+ function applicationIds(gradleSources) {
34
+ const ids = new Set();
35
+ for (const src of gradleSources) {
36
+ for (const m of src.matchAll(/applicationId\s*(?:=|\s)\s*["']([^"']+)["']/g)) {
37
+ ids.add(m[1]);
38
+ }
39
+ }
40
+ return [...ids];
41
+ }
42
+ export async function discoverAndroid(root) {
43
+ const manifests = (await walk(root, (n) => n === 'AndroidManifest.xml')).filter(
44
+ // androidTest/debug manifests rarely declare shipping deep links
45
+ (p) => !/src\/(androidTest|test)\//.test(p));
46
+ if (manifests.length === 0)
47
+ return [];
48
+ const index = emptyIndex();
49
+ const gradlePaths = await walk(root, (n) => n === 'build.gradle' || n === 'build.gradle.kts');
50
+ await indexStrings((await walk(root, (n) => n.endsWith('.xml'))).filter((p) => /res\/values[^/]*\//.test(p)), index);
51
+ await indexResValues(gradlePaths, index);
52
+ await indexProperties(await walk(root, (n) => n.endsWith('.properties')), index);
53
+ const gradleSources = await Promise.all(gradlePaths.map((p) => readFile(p, 'utf8')));
54
+ const packageIds = applicationIds(gradleSources);
55
+ const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' });
56
+ const apps = [];
57
+ for (const manifestPath of manifests) {
58
+ const parsed = parser.parse(await readFile(manifestPath, 'utf8'));
59
+ const filters = [];
60
+ collectIntentFilters(parsed, filters);
61
+ const hosts = new Map();
62
+ const unresolved = [];
63
+ for (const filter of filters) {
64
+ const node = filter;
65
+ const actions = asArray(node['action']).map((a) => attr(a, 'android:name'));
66
+ const categories = asArray(node['category']).map((c) => attr(c, 'android:name'));
67
+ if (!actions.includes(VIEW_ACTION))
68
+ continue;
69
+ if (!categories.includes(BROWSABLE))
70
+ continue;
71
+ const autoVerify = attr(node, 'android:autoVerify') === 'true';
72
+ const dataNodes = asArray(node['data']);
73
+ // scheme/host/path are declared across sibling <data> elements and merge per filter
74
+ const schemes = new Set();
75
+ const paths = new Set();
76
+ const rawHosts = [];
77
+ for (const data of dataNodes) {
78
+ for (const raw of [attr(data, 'android:scheme')].filter(Boolean)) {
79
+ resolveRef(raw, index).values.forEach((v) => schemes.add(v));
80
+ }
81
+ for (const key of ['android:path', 'android:pathPrefix', 'android:pathPattern']) {
82
+ const raw = attr(data, key);
83
+ if (!raw)
84
+ continue;
85
+ // a prefix implicitly matches everything below it; make that explicit so it
86
+ // lines up with the glob style AASA uses
87
+ const suffix = key === 'android:pathPrefix' ? '*' : '';
88
+ resolveRef(raw, index).values.forEach((v) => paths.add(`${v}${suffix}`));
89
+ }
90
+ const rawHost = attr(data, 'android:host');
91
+ if (rawHost)
92
+ rawHosts.push(rawHost);
93
+ }
94
+ // Only http(s) filters are App Links. A custom scheme like `bitwarden://totp`
95
+ // also carries a host, but it has no assetlinks counterpart and nothing to verify.
96
+ if (!schemes.has('http') && !schemes.has('https'))
97
+ continue;
98
+ for (const raw of rawHosts) {
99
+ const resolution = resolveRef(raw, index);
100
+ if (resolution.values.length === 0) {
101
+ unresolved.push({ raw, reason: resolution.unresolved ?? 'resolution failed' });
102
+ continue;
103
+ }
104
+ for (const host of resolution.values) {
105
+ const existing = hosts.get(host);
106
+ if (existing) {
107
+ existing.autoVerify ||= autoVerify;
108
+ schemes.forEach((s) => existing.schemes.includes(s) || existing.schemes.push(s));
109
+ paths.forEach((p) => existing.paths.includes(p) || existing.paths.push(p));
110
+ }
111
+ else {
112
+ hosts.set(host, {
113
+ host,
114
+ raw,
115
+ schemes: [...schemes],
116
+ paths: [...paths],
117
+ autoVerify,
118
+ });
119
+ }
120
+ }
121
+ }
122
+ }
123
+ if (hosts.size === 0 && unresolved.length === 0)
124
+ continue;
125
+ apps.push({
126
+ manifestPath: relative(root, manifestPath),
127
+ packageIds,
128
+ hosts: [...hosts.values()],
129
+ unresolved,
130
+ });
131
+ }
132
+ return apps;
133
+ }
@@ -0,0 +1,91 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { relative } from 'node:path';
3
+ import plist from 'plist';
4
+ import { walk } from './walk.js';
5
+ /**
6
+ * Associated Domains entries look like `applinks:example.com`. A domain may carry
7
+ * query-parameter options (`?mode=developer`) which are not part of the host.
8
+ */
9
+ function parseAssociatedDomains(entries) {
10
+ if (!Array.isArray(entries))
11
+ return [];
12
+ const domains = [];
13
+ for (const raw of entries) {
14
+ if (typeof raw !== 'string')
15
+ continue;
16
+ if (!raw.startsWith('applinks:'))
17
+ continue;
18
+ const host = raw.slice('applinks:'.length).split('?')[0].trim();
19
+ if (host && !domains.includes(host))
20
+ domains.push(host);
21
+ }
22
+ return domains;
23
+ }
24
+ /**
25
+ * Build settings can be literals or `$(VARIABLE)` references. We only take literals —
26
+ * a reference means the real value lives in an xcconfig we are not resolving yet.
27
+ */
28
+ function literalSetting(block, key) {
29
+ const m = block.match(new RegExp(`\\n\\s*${key}\\s*=\\s*"?([^";\\n]+)"?;`));
30
+ const value = m?.[1]?.trim();
31
+ if (!value || value.includes('$(') || value === '""')
32
+ return undefined;
33
+ return value;
34
+ }
35
+ /**
36
+ * A project has one bundle id per target, not one per project. The entitlements path
37
+ * and the bundle id live in the same `buildSettings` block, so pairing them there
38
+ * avoids attributing the widget's identifier to the app.
39
+ */
40
+ function signingByEntitlements(pbxproj) {
41
+ const map = new Map();
42
+ for (const m of pbxproj.matchAll(/buildSettings = \{([\s\S]*?)\n\s*\};/g)) {
43
+ const block = m[1];
44
+ const entitlements = literalSetting(block, 'CODE_SIGN_ENTITLEMENTS');
45
+ if (!entitlements)
46
+ continue;
47
+ const existing = map.get(entitlements) ?? {};
48
+ map.set(entitlements, {
49
+ bundleId: existing.bundleId ?? literalSetting(block, 'PRODUCT_BUNDLE_IDENTIFIER'),
50
+ teamId: existing.teamId ?? literalSetting(block, 'DEVELOPMENT_TEAM'),
51
+ });
52
+ }
53
+ return map;
54
+ }
55
+ export async function discoverIos(root) {
56
+ const entitlementFiles = await walk(root, (n) => n.endsWith('.entitlements'));
57
+ if (entitlementFiles.length === 0)
58
+ return [];
59
+ const pbxprojPaths = await walk(root, (n) => n === 'project.pbxproj');
60
+ const signing = new Map();
61
+ for (const path of pbxprojPaths) {
62
+ for (const [k, v] of signingByEntitlements(await readFile(path, 'utf8'))) {
63
+ if (!signing.has(k))
64
+ signing.set(k, v);
65
+ }
66
+ }
67
+ const apps = [];
68
+ for (const path of entitlementFiles) {
69
+ let parsed;
70
+ try {
71
+ parsed = plist.parse(await readFile(path, 'utf8'));
72
+ }
73
+ catch {
74
+ continue;
75
+ }
76
+ const dict = parsed;
77
+ const domains = parseAssociatedDomains(dict['com.apple.developer.associated-domains']);
78
+ if (domains.length === 0)
79
+ continue;
80
+ // pbxproj records the entitlements path relative to SOURCE_ROOT
81
+ const rel = relative(root, path);
82
+ const target = signing.get(rel) ?? {};
83
+ apps.push({
84
+ entitlementsPath: rel,
85
+ domains,
86
+ bundleId: target.bundleId,
87
+ teamId: target.teamId,
88
+ });
89
+ }
90
+ return apps;
91
+ }
@@ -0,0 +1,36 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ const SKIP_DIRS = new Set([
4
+ 'node_modules',
5
+ 'Pods',
6
+ 'Carthage',
7
+ '.git',
8
+ 'build',
9
+ 'DerivedData',
10
+ 'dist',
11
+ '.build',
12
+ '.gradle',
13
+ '.idea',
14
+ ]);
15
+ /** Depth-first file scan that skips dependency and build output directories. */
16
+ export async function walk(dir, match, out = []) {
17
+ let entries;
18
+ try {
19
+ entries = await readdir(dir, { withFileTypes: true });
20
+ }
21
+ catch {
22
+ return out;
23
+ }
24
+ for (const entry of entries) {
25
+ if (entry.isDirectory()) {
26
+ if (SKIP_DIRS.has(entry.name))
27
+ continue;
28
+ // .xcodeproj is a directory but holds project.pbxproj
29
+ await walk(join(dir, entry.name), match, out);
30
+ }
31
+ else if (match(entry.name)) {
32
+ out.push(join(dir, entry.name));
33
+ }
34
+ }
35
+ return out;
36
+ }
@@ -0,0 +1,86 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ const TIMEOUT_MS = 10_000;
4
+ export const AASA_FILE = 'apple-app-site-association';
5
+ export const ASSETLINKS_FILE = 'assetlinks.json';
6
+ export function aasaUrl(domain) {
7
+ return `https://${domain}/.well-known/${AASA_FILE}`;
8
+ }
9
+ export function assetlinksUrl(domain) {
10
+ return `https://${domain}/.well-known/${ASSETLINKS_FILE}`;
11
+ }
12
+ /**
13
+ * Apple does not follow redirects when fetching an AASA file, so neither do we —
14
+ * a 3xx here is the finding, not something to chase.
15
+ */
16
+ async function fetchRaw(url) {
17
+ const controller = new AbortController();
18
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
19
+ try {
20
+ const res = await fetch(url, {
21
+ redirect: 'manual',
22
+ signal: controller.signal,
23
+ headers: { accept: 'application/json' },
24
+ });
25
+ const redirected = res.status >= 300 && res.status < 400;
26
+ return {
27
+ url,
28
+ ok: res.status === 200,
29
+ status: res.status,
30
+ redirected,
31
+ location: res.headers.get('location') ?? undefined,
32
+ contentType: res.headers.get('content-type') ?? undefined,
33
+ body: redirected ? undefined : await res.text(),
34
+ };
35
+ }
36
+ catch (err) {
37
+ const aborted = err instanceof Error && err.name === 'AbortError';
38
+ return {
39
+ url,
40
+ ok: false,
41
+ redirected: false,
42
+ error: aborted
43
+ ? `timed out after ${TIMEOUT_MS / 1000}s`
44
+ : err instanceof Error
45
+ ? err.message
46
+ : String(err),
47
+ };
48
+ }
49
+ finally {
50
+ clearTimeout(timer);
51
+ }
52
+ }
53
+ export function networkSource() {
54
+ return {
55
+ aasa: (domain) => fetchRaw(aasaUrl(domain)),
56
+ assetlinks: (domain) => fetchRaw(assetlinksUrl(domain)),
57
+ };
58
+ }
59
+ /**
60
+ * Read well-known files from disk instead of the network, laid out as
61
+ * `<dir>/<domain>/<file>`. Lets CI run without egress, and lets a web team validate
62
+ * staged files before they are deployed. A missing file is reported as a 404 so the
63
+ * rules behave exactly as they would against a live host.
64
+ */
65
+ export function localSource(dir) {
66
+ const read = async (domain, file) => {
67
+ const path = join(dir, domain, file);
68
+ try {
69
+ return {
70
+ url: path,
71
+ ok: true,
72
+ status: 200,
73
+ redirected: false,
74
+ contentType: 'application/json',
75
+ body: await readFile(path, 'utf8'),
76
+ };
77
+ }
78
+ catch {
79
+ return { url: path, ok: false, status: 404, redirected: false };
80
+ }
81
+ };
82
+ return {
83
+ aasa: (domain) => read(domain, AASA_FILE),
84
+ assetlinks: (domain) => read(domain, ASSETLINKS_FILE),
85
+ };
86
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * AASA has two shapes for `applinks.details`:
3
+ * legacy — an array of `{ appID, paths }`, or an object keyed by appID
4
+ * current — an array of `{ appIDs, components }`
5
+ * Both are still honoured by iOS, so we normalise rather than pick a side.
6
+ */
7
+ export function parseAasa(body) {
8
+ let json;
9
+ try {
10
+ json = JSON.parse(body);
11
+ }
12
+ catch (err) {
13
+ return { error: err instanceof Error ? err.message : 'invalid JSON' };
14
+ }
15
+ const root = json;
16
+ const applinks = root?.['applinks'];
17
+ if (!applinks)
18
+ return { error: 'no "applinks" key' };
19
+ const rawDetails = applinks['details'];
20
+ const details = [];
21
+ const pushDetail = (entry, fallbackAppId) => {
22
+ const appIds = [];
23
+ if (Array.isArray(entry['appIDs'])) {
24
+ for (const id of entry['appIDs'])
25
+ if (typeof id === 'string')
26
+ appIds.push(id);
27
+ }
28
+ if (typeof entry['appID'] === 'string')
29
+ appIds.push(entry['appID']);
30
+ if (appIds.length === 0 && fallbackAppId)
31
+ appIds.push(fallbackAppId);
32
+ details.push({
33
+ appIds,
34
+ paths: Array.isArray(entry['paths'])
35
+ ? entry['paths'].filter((p) => typeof p === 'string')
36
+ : undefined,
37
+ components: Array.isArray(entry['components']) ? entry['components'] : undefined,
38
+ });
39
+ };
40
+ if (Array.isArray(rawDetails)) {
41
+ for (const entry of rawDetails) {
42
+ if (entry && typeof entry === 'object')
43
+ pushDetail(entry);
44
+ }
45
+ }
46
+ else if (rawDetails && typeof rawDetails === 'object') {
47
+ // legacy object form: { "TEAMID.bundle.id": { paths: [...] } }
48
+ for (const [appId, entry] of Object.entries(rawDetails)) {
49
+ if (entry && typeof entry === 'object') {
50
+ pushDetail(entry, appId);
51
+ }
52
+ }
53
+ }
54
+ else {
55
+ return { error: '"applinks.details" missing or not an array/object' };
56
+ }
57
+ return { aasa: { details } };
58
+ }
59
+ /** An AASA appID may be `TEAMID.bundle.id` or the wildcard `TEAMID.*`. */
60
+ export function appIdMatches(declared, teamId, bundleId) {
61
+ const expected = `${teamId}.${bundleId}`;
62
+ if (declared === expected)
63
+ return true;
64
+ if (declared === `${teamId}.*`)
65
+ return true;
66
+ return false;
67
+ }
@@ -0,0 +1,40 @@
1
+ export const HANDLE_ALL_URLS = 'delegate_permission/common.handle_all_urls';
2
+ /**
3
+ * assetlinks.json is a top-level array of statements. Only `android_app` targets
4
+ * matter here; web targets in the same file are ignored rather than treated as errors.
5
+ */
6
+ export function parseAssetlinks(body) {
7
+ let json;
8
+ try {
9
+ json = JSON.parse(body);
10
+ }
11
+ catch (err) {
12
+ return { error: err instanceof Error ? err.message : 'invalid JSON' };
13
+ }
14
+ if (!Array.isArray(json))
15
+ return { error: 'top level is not an array' };
16
+ const statements = [];
17
+ for (const entry of json) {
18
+ if (!entry || typeof entry !== 'object')
19
+ continue;
20
+ const record = entry;
21
+ const target = record['target'];
22
+ if (!target || target['namespace'] !== 'android_app')
23
+ continue;
24
+ const fingerprints = Array.isArray(target['sha256_cert_fingerprints'])
25
+ ? target['sha256_cert_fingerprints'].filter((f) => typeof f === 'string')
26
+ : [];
27
+ statements.push({
28
+ relations: Array.isArray(record['relation'])
29
+ ? record['relation'].filter((r) => typeof r === 'string')
30
+ : [],
31
+ packageName: typeof target['package_name'] === 'string' ? target['package_name'] : undefined,
32
+ fingerprints,
33
+ });
34
+ }
35
+ return { statements };
36
+ }
37
+ /** Fingerprints are colon-separated hex; comparison ignores case and separators. */
38
+ export function normalizeFingerprint(value) {
39
+ return value.replace(/[^a-fA-F0-9]/g, '').toUpperCase();
40
+ }
@@ -0,0 +1,40 @@
1
+ const COLOR = {
2
+ error: '',
3
+ warn: '',
4
+ info: '',
5
+ };
6
+ const DIM = '';
7
+ const BOLD = '';
8
+ const RESET = '';
9
+ const LABEL = { error: 'ERROR', warn: 'WARN ', info: 'INFO ' };
10
+ const ORDER = ['error', 'warn', 'info'];
11
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
12
+ const paint = (s, c) => (useColor ? `${c}${s}${RESET}` : s);
13
+ export function printReport(findings, checkedDomains) {
14
+ console.log(`\n${paint('deeplink-parity', BOLD)} ${DIM}·${RESET} ${checkedDomains.length} domain(s) checked\n`);
15
+ if (findings.length === 0) {
16
+ console.log(paint('No problems found', ''));
17
+ console.log();
18
+ return;
19
+ }
20
+ for (const severity of ORDER) {
21
+ for (const f of findings.filter((x) => x.severity === severity)) {
22
+ const head = paint(LABEL[severity], COLOR[severity]);
23
+ // Not every finding is tied to a domain — fall back to the rule id so the line is never blank
24
+ const subject = f.domain ? paint(f.domain, BOLD) : paint(f.rule, DIM);
25
+ console.log(`${head} ${subject}`);
26
+ console.log(` ${f.message}`);
27
+ if (f.detail)
28
+ console.log(` ${paint(f.detail, DIM)}`);
29
+ if (f.source)
30
+ console.log(` ${paint(f.source, DIM)}`);
31
+ console.log();
32
+ }
33
+ }
34
+ const counts = ORDER.map((s) => `${findings.filter((f) => f.severity === s).length} ${s}`);
35
+ console.log(counts.join(', '));
36
+ console.log();
37
+ }
38
+ export function exitCodeFor(findings) {
39
+ return findings.some((f) => f.severity === 'error') ? 1 : 0;
40
+ }
@@ -0,0 +1,120 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ const MAX_DEPTH = 8;
3
+ export function emptyIndex() {
4
+ return { strings: new Map(), resValues: new Map(), properties: new Map() };
5
+ }
6
+ function stripQuotes(value) {
7
+ const trimmed = value.trim();
8
+ const quoted = /^(["'])([\s\S]*)\1$/.exec(trimmed);
9
+ return quoted ? quoted[2] : trimmed;
10
+ }
11
+ export async function indexStrings(paths, index) {
12
+ for (const path of paths) {
13
+ const xml = await readFile(path, 'utf8');
14
+ for (const m of xml.matchAll(/<string\s+[^>]*name="([^"]+)"[^>]*>([\s\S]*?)<\/string>/g)) {
15
+ if (!index.strings.has(m[1]))
16
+ index.strings.set(m[1], m[2].trim());
17
+ }
18
+ }
19
+ }
20
+ /**
21
+ * Matches both the Kotlin DSL (`resValue(type = "string", name = "x", value = expr)`,
22
+ * arguments in any order) and the Groovy form (`resValue "string", "x", expr`).
23
+ * Flavor attribution is deliberately skipped — a name that differs across variants
24
+ * yields several candidates and we check all of them, which is what a checker wants.
25
+ */
26
+ export async function indexResValues(paths, index) {
27
+ for (const path of paths) {
28
+ const src = await readFile(path, 'utf8');
29
+ for (const m of src.matchAll(/resValue\s*\(([\s\S]*?)\)\s*(?:\n|$)/g)) {
30
+ const args = m[1];
31
+ if (!/["']string["']/.test(args))
32
+ continue;
33
+ const name = /name\s*=\s*["']([^"']+)["']/.exec(args)?.[1];
34
+ const value = /value\s*=\s*([\s\S]+?)\s*$/.exec(args)?.[1];
35
+ if (!name || !value)
36
+ continue;
37
+ const list = index.resValues.get(name) ?? [];
38
+ list.push(value.trim());
39
+ index.resValues.set(name, list);
40
+ }
41
+ for (const m of src.matchAll(/resValue\s+["']string["']\s*,\s*["']([^"']+)["']\s*,\s*(.+)/g)) {
42
+ const list = index.resValues.get(m[1]) ?? [];
43
+ list.push(m[2].trim());
44
+ index.resValues.set(m[1], list);
45
+ }
46
+ }
47
+ }
48
+ export async function indexProperties(paths, index) {
49
+ for (const path of paths) {
50
+ const text = await readFile(path, 'utf8');
51
+ for (const line of text.split('\n')) {
52
+ const trimmed = line.trim();
53
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!'))
54
+ continue;
55
+ const eq = trimmed.indexOf('=');
56
+ if (eq <= 0)
57
+ continue;
58
+ const key = trimmed.slice(0, eq).trim();
59
+ if (!index.properties.has(key)) {
60
+ index.properties.set(key, stripQuotes(trimmed.slice(eq + 1)));
61
+ }
62
+ }
63
+ }
64
+ }
65
+ /**
66
+ * Reduce a gradle value expression to a literal.
67
+ * Handles string literals and property lookups — `props["KEY"]`, `props.getProperty("KEY")`,
68
+ * with or without a trailing cast. Anything else is left unresolved rather than guessed at.
69
+ */
70
+ function resolveGradleExpression(expr, index) {
71
+ const literal = /^(["'])([^"']*)\1(?:\s+as\s+\w+)?$/.exec(expr.trim());
72
+ if (literal)
73
+ return literal[2];
74
+ const lookup = /\[\s*["']([^"']+)["']\s*\]/.exec(expr) ?? /getProperty\(\s*["']([^"']+)["']\s*\)/.exec(expr);
75
+ if (lookup)
76
+ return index.properties.get(lookup[1]);
77
+ return undefined;
78
+ }
79
+ /**
80
+ * Expand a manifest attribute value. `@string/x` may chain through another `@string/y`
81
+ * before landing on a literal, so resolution recurses with a depth guard.
82
+ */
83
+ export function resolveRef(raw, index, depth = 0) {
84
+ const value = raw.trim();
85
+ if (!value.startsWith('@string/'))
86
+ return { values: value ? [value] : [] };
87
+ if (depth >= MAX_DEPTH)
88
+ return { values: [], unresolved: 'reference chain is too deep or circular' };
89
+ const name = value.slice('@string/'.length);
90
+ const candidates = [];
91
+ const fromStrings = index.strings.get(name);
92
+ if (fromStrings !== undefined)
93
+ candidates.push(fromStrings);
94
+ let sawUnresolvedExpression = false;
95
+ for (const expr of index.resValues.get(name) ?? []) {
96
+ const literal = resolveGradleExpression(expr, index);
97
+ if (literal === undefined)
98
+ sawUnresolvedExpression = true;
99
+ else
100
+ candidates.push(literal);
101
+ }
102
+ if (candidates.length === 0) {
103
+ return {
104
+ values: [],
105
+ unresolved: sawUnresolvedExpression
106
+ ? `could not resolve the gradle resValue expression for ${name}`
107
+ : `no definition found for @string/${name}`,
108
+ };
109
+ }
110
+ const values = new Set();
111
+ let unresolved = sawUnresolvedExpression
112
+ ? `some gradle resValue values for ${name} could not be resolved`
113
+ : undefined;
114
+ for (const candidate of candidates) {
115
+ const nested = resolveRef(candidate, index, depth + 1);
116
+ nested.values.forEach((v) => values.add(v));
117
+ unresolved ??= nested.unresolved;
118
+ }
119
+ return { values: [...values], unresolved: values.size > 0 ? undefined : unresolved };
120
+ }
@@ -0,0 +1,109 @@
1
+ import { HANDLE_ALL_URLS, normalizeFingerprint, parseAssetlinks } from '../parse/assetlinks.js';
2
+ export function checkAndroidHost(app, entry, res, options) {
3
+ const findings = [];
4
+ const domain = entry.host;
5
+ const source = res.url;
6
+ // Only autoVerify hosts are verified by the system; the rest open a chooser by design
7
+ if (!entry.autoVerify) {
8
+ findings.push({
9
+ severity: 'warn',
10
+ rule: 'intent-filter-no-autoverify',
11
+ domain,
12
+ message: 'This https intent-filter has no autoVerify',
13
+ detail: 'App Links stay unverified, so the user sees a disambiguation dialog',
14
+ source: app.manifestPath,
15
+ });
16
+ return findings;
17
+ }
18
+ if (res.error || !res.ok) {
19
+ findings.push({
20
+ severity: 'error',
21
+ rule: 'assetlinks-unreachable',
22
+ domain,
23
+ message: res.error
24
+ ? `Could not fetch assetlinks.json — ${res.error}`
25
+ : `assetlinks.json responded with ${res.status}`,
26
+ detail: 'App Link verification fails and links fall through to the browser',
27
+ source,
28
+ });
29
+ return findings;
30
+ }
31
+ const { statements, error } = parseAssetlinks(res.body ?? '');
32
+ if (!statements) {
33
+ findings.push({
34
+ severity: 'error',
35
+ rule: 'assetlinks-invalid',
36
+ domain,
37
+ message: `Could not parse assetlinks.json — ${error}`,
38
+ source,
39
+ });
40
+ return findings;
41
+ }
42
+ const handling = statements.filter((s) => s.relations.includes(HANDLE_ALL_URLS));
43
+ if (handling.length === 0) {
44
+ findings.push({
45
+ severity: 'error',
46
+ rule: 'assetlinks-no-statement',
47
+ domain,
48
+ message: `assetlinks.json declares no ${HANDLE_ALL_URLS} statement`,
49
+ source,
50
+ });
51
+ return findings;
52
+ }
53
+ if (app.packageIds.length === 0) {
54
+ findings.push({
55
+ severity: 'info',
56
+ rule: 'package-unknown',
57
+ domain,
58
+ message: 'Skipped the package_name check — could not determine the applicationId',
59
+ source: app.manifestPath,
60
+ });
61
+ return findings;
62
+ }
63
+ const matched = handling.filter((s) => s.packageName && app.packageIds.includes(s.packageName));
64
+ if (matched.length === 0) {
65
+ findings.push({
66
+ severity: 'error',
67
+ rule: 'assetlinks-package-missing',
68
+ domain,
69
+ message: `assetlinks.json does not list ${app.packageIds.join(' / ')}`,
70
+ detail: `Declared package_name: ${handling.map((s) => s.packageName ?? '(none)').join(', ')}`,
71
+ source,
72
+ });
73
+ return findings;
74
+ }
75
+ if (options.sha256) {
76
+ const wanted = normalizeFingerprint(options.sha256);
77
+ const declared = matched.flatMap((s) => s.fingerprints.map(normalizeFingerprint));
78
+ if (!declared.includes(wanted)) {
79
+ findings.push({
80
+ severity: 'error',
81
+ rule: 'assetlinks-fingerprint-missing',
82
+ domain,
83
+ message: 'The signing fingerprint is not listed in assetlinks.json',
84
+ detail: `None of the ${declared.length} declared fingerprint(s) match`,
85
+ source,
86
+ });
87
+ }
88
+ }
89
+ else {
90
+ findings.push({
91
+ severity: 'info',
92
+ rule: 'fingerprint-skipped',
93
+ domain,
94
+ message: 'Skipped the signing fingerprint check',
95
+ detail: 'Pass --sha256 <fingerprint> to enable it',
96
+ source,
97
+ });
98
+ }
99
+ return findings;
100
+ }
101
+ export function checkAndroidUnresolved(app) {
102
+ return app.unresolved.map((u) => ({
103
+ severity: 'warn',
104
+ rule: 'host-unresolved',
105
+ message: `Could not resolve host ${u.raw}`,
106
+ detail: `${u.reason}. This host was skipped.`,
107
+ source: app.manifestPath,
108
+ }));
109
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Android declares prefixes and patterns; AASA declares glob-ish paths. Normalising both
3
+ * to a lowercase glob makes the common cases comparable without pretending the two
4
+ * matching engines are equivalent.
5
+ */
6
+ function normalizePath(path) {
7
+ return path
8
+ .trim()
9
+ .toLowerCase()
10
+ .replace(/^not\s+/, '!')
11
+ // `.*` is the pathPattern spelling of a wildcard
12
+ .replace(/\.\*/g, '*')
13
+ .replace(/\*+$/, '*')
14
+ // `/item/*` and the prefix form `/item*` describe the same subtree
15
+ .replace(/\/\*$/, '*')
16
+ .replace(/\/$/, '');
17
+ }
18
+ function normalizeSet(paths) {
19
+ return new Set(paths.map(normalizePath).filter(Boolean));
20
+ }
21
+ /**
22
+ * The reason this tool exists: existing validators look at one platform, so a domain
23
+ * that works on iOS and silently fails on Android goes unnoticed.
24
+ */
25
+ export function checkCrossPlatform(ios, android) {
26
+ const findings = [];
27
+ // A platform with no declarations at all is a single-platform repo, not a gap
28
+ if (ios.domains.size === 0 || android.domains.size === 0)
29
+ return findings;
30
+ for (const domain of ios.domains) {
31
+ if (android.domains.has(domain))
32
+ continue;
33
+ findings.push({
34
+ severity: 'warn',
35
+ rule: 'platform-domain-gap',
36
+ domain,
37
+ message: 'Declared on iOS but not on Android',
38
+ detail: 'The same link opens the app on iOS and the browser on Android',
39
+ });
40
+ }
41
+ for (const domain of android.domains) {
42
+ if (ios.domains.has(domain))
43
+ continue;
44
+ findings.push({
45
+ severity: 'warn',
46
+ rule: 'platform-domain-gap',
47
+ domain,
48
+ message: 'Declared on Android but not on iOS',
49
+ detail: 'The same link opens the app on Android and the browser on iOS',
50
+ });
51
+ }
52
+ for (const domain of ios.domains) {
53
+ if (!android.domains.has(domain))
54
+ continue;
55
+ const iosPaths = normalizeSet(ios.paths.get(domain) ?? []);
56
+ const androidPaths = normalizeSet(android.paths.get(domain) ?? []);
57
+ // Either side declaring nothing means "all paths", which is not a mismatch to report
58
+ if (iosPaths.size === 0 || androidPaths.size === 0)
59
+ continue;
60
+ const onlyIos = [...iosPaths].filter((p) => !androidPaths.has(p));
61
+ const onlyAndroid = [...androidPaths].filter((p) => !iosPaths.has(p));
62
+ if (onlyIos.length === 0 && onlyAndroid.length === 0)
63
+ continue;
64
+ findings.push({
65
+ severity: 'info',
66
+ rule: 'platform-path-gap',
67
+ domain,
68
+ message: 'The declared path sets differ between platforms',
69
+ detail: [
70
+ onlyIos.length ? `iOS only: ${onlyIos.join(', ')}` : '',
71
+ onlyAndroid.length ? `Android only: ${onlyAndroid.join(', ')}` : '',
72
+ 'Path matching differs between the two systems, so review this by hand.',
73
+ ]
74
+ .filter(Boolean)
75
+ .join(' · '),
76
+ });
77
+ }
78
+ return findings;
79
+ }
@@ -0,0 +1,125 @@
1
+ import { appIdMatches, parseAasa } from '../parse/aasa.js';
2
+ /**
3
+ * Per-domain iOS checks. Each stage stops the chain when a later check could not
4
+ * possibly be meaningful — an unreachable AASA says nothing about its appIDs.
5
+ */
6
+ export function checkIosDomain(app, domain, res) {
7
+ const findings = [];
8
+ const source = res.url;
9
+ if (res.error) {
10
+ findings.push({
11
+ severity: 'error',
12
+ rule: 'aasa-unreachable',
13
+ domain,
14
+ message: `Could not fetch the AASA file — ${res.error}`,
15
+ detail: 'Universal Links fall through to the browser',
16
+ source,
17
+ });
18
+ return { findings };
19
+ }
20
+ if (res.redirected) {
21
+ findings.push({
22
+ severity: 'error',
23
+ rule: 'aasa-redirect',
24
+ domain,
25
+ message: `AASA responded with ${res.status}${res.location ? ` → ${res.location}` : ''}`,
26
+ detail: 'iOS does not follow redirects when fetching an AASA file',
27
+ source,
28
+ });
29
+ return { findings };
30
+ }
31
+ if (!res.ok) {
32
+ findings.push({
33
+ severity: 'error',
34
+ rule: 'aasa-unreachable',
35
+ domain,
36
+ message: `AASA responded with ${res.status}`,
37
+ detail: 'Universal Links fall through to the browser',
38
+ source,
39
+ });
40
+ return { findings };
41
+ }
42
+ const { aasa, error } = parseAasa(res.body ?? '');
43
+ if (!aasa) {
44
+ findings.push({
45
+ severity: 'error',
46
+ rule: 'aasa-invalid',
47
+ domain,
48
+ message: `Could not parse the AASA file — ${error}`,
49
+ source,
50
+ });
51
+ return { findings };
52
+ }
53
+ if (res.contentType && !res.contentType.includes('application/json')) {
54
+ findings.push({
55
+ severity: 'warn',
56
+ rule: 'aasa-content-type',
57
+ domain,
58
+ message: `content-type is ${res.contentType}`,
59
+ detail: 'application/json is recommended',
60
+ source,
61
+ });
62
+ }
63
+ const declaredAppIds = aasa.details.flatMap((d) => d.appIds);
64
+ if (declaredAppIds.length === 0) {
65
+ findings.push({
66
+ severity: 'error',
67
+ rule: 'aasa-no-appids',
68
+ domain,
69
+ message: 'The AASA file declares no appIDs',
70
+ source,
71
+ });
72
+ }
73
+ else if (app.teamId && app.bundleId) {
74
+ const matched = declaredAppIds.some((id) => appIdMatches(id, app.teamId, app.bundleId));
75
+ if (!matched) {
76
+ findings.push({
77
+ severity: 'error',
78
+ rule: 'aasa-appid-missing',
79
+ domain,
80
+ message: `The AASA file does not list ${app.teamId}.${app.bundleId}`,
81
+ detail: `Declared appIDs: ${declaredAppIds.join(', ')}`,
82
+ source,
83
+ });
84
+ }
85
+ }
86
+ else {
87
+ findings.push({
88
+ severity: 'info',
89
+ rule: 'appid-unknown',
90
+ domain,
91
+ message: 'Skipped the appID check — could not determine the Team ID or bundle ID',
92
+ detail: 'The pbxproj value may be an xcconfig variable',
93
+ source: app.entitlementsPath,
94
+ });
95
+ }
96
+ const allEmpty = aasa.details.length > 0 &&
97
+ aasa.details.every((d) => (d.paths?.length ?? 0) === 0 && (d.components?.length ?? 0) === 0);
98
+ if (allEmpty) {
99
+ findings.push({
100
+ severity: 'info',
101
+ rule: 'aasa-no-paths',
102
+ domain,
103
+ message: 'The AASA file declares no paths or components',
104
+ detail: 'Depending on the format this opens every path in the app, or none of them',
105
+ source,
106
+ });
107
+ }
108
+ return { findings, aasa };
109
+ }
110
+ /** Flatten the legacy `paths` and current `components` forms into comparable strings. */
111
+ export function aasaPaths(aasa) {
112
+ const paths = new Set();
113
+ for (const detail of aasa.details) {
114
+ for (const path of detail.paths ?? [])
115
+ paths.add(path);
116
+ for (const component of detail.components ?? []) {
117
+ if (!component || typeof component !== 'object')
118
+ continue;
119
+ const value = component['/'];
120
+ if (typeof value === 'string')
121
+ paths.add(value);
122
+ }
123
+ }
124
+ return [...paths];
125
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `applinks:*.example.com` (and the Android equivalent) is a valid declaration, but
3
+ * there is no host to fetch: the well-known file has to exist on each concrete
4
+ * subdomain, and we cannot enumerate those from the repo.
5
+ */
6
+ export function isWildcardDomain(domain) {
7
+ return domain.startsWith('*.');
8
+ }
9
+ export function wildcardFinding(domain, source) {
10
+ return {
11
+ severity: 'info',
12
+ rule: 'wildcard-domain',
13
+ domain,
14
+ message: 'Wildcard domain — skipped',
15
+ detail: 'Every concrete subdomain must serve its own well-known file. Pass one explicitly to check it.',
16
+ source,
17
+ };
18
+ }
package/dist/run.js ADDED
@@ -0,0 +1,57 @@
1
+ import { discoverAndroid } from './discover/android.js';
2
+ import { discoverIos } from './discover/ios.js';
3
+ import { checkAndroidHost, checkAndroidUnresolved } from './rules/android.js';
4
+ import { checkCrossPlatform } from './rules/cross.js';
5
+ import { aasaPaths, checkIosDomain } from './rules/ios.js';
6
+ import { isWildcardDomain, wildcardFinding } from './rules/wildcard.js';
7
+ function emptyView() {
8
+ return { domains: new Set(), paths: new Map() };
9
+ }
10
+ export async function run({ root, source, sha256 }) {
11
+ const [iosApps, androidApps] = await Promise.all([discoverIos(root), discoverAndroid(root)]);
12
+ const findings = [];
13
+ const ios = emptyView();
14
+ const android = emptyView();
15
+ for (const app of iosApps) {
16
+ // the same domain can be declared by several targets; fetch it once
17
+ const fresh = app.domains.filter((d) => !ios.domains.has(d));
18
+ fresh.forEach((d) => ios.domains.add(d));
19
+ const wildcards = fresh.filter(isWildcardDomain);
20
+ wildcards.forEach((d) => findings.push(wildcardFinding(d, app.entitlementsPath)));
21
+ const pending = fresh.filter((d) => !isWildcardDomain(d));
22
+ const results = await Promise.all(pending.map((d) => source.aasa(d)));
23
+ pending.forEach((domain, i) => {
24
+ const { findings: domainFindings, aasa } = checkIosDomain(app, domain, results[i]);
25
+ findings.push(...domainFindings);
26
+ if (aasa)
27
+ ios.paths.set(domain, aasaPaths(aasa));
28
+ });
29
+ }
30
+ for (const app of androidApps) {
31
+ findings.push(...checkAndroidUnresolved(app));
32
+ const fresh = app.hosts.filter((h) => !android.domains.has(h.host));
33
+ fresh.forEach((h) => {
34
+ android.domains.add(h.host);
35
+ if (h.paths.length > 0)
36
+ android.paths.set(h.host, h.paths);
37
+ });
38
+ fresh
39
+ .filter((h) => isWildcardDomain(h.host))
40
+ .forEach((h) => findings.push(wildcardFinding(h.host, app.manifestPath)));
41
+ const pending = fresh.filter((h) => !isWildcardDomain(h.host));
42
+ const results = await Promise.all(
43
+ // an unverified host is never checked by the system, so there is nothing to fetch
44
+ pending.map((h) => (h.autoVerify ? source.assetlinks(h.host) : Promise.resolve(null))));
45
+ pending.forEach((entry, i) => {
46
+ const res = results[i] ?? { url: '', ok: false, redirected: false };
47
+ findings.push(...checkAndroidHost(app, entry, res, { sha256 }));
48
+ });
49
+ }
50
+ findings.push(...checkCrossPlatform(ios, android));
51
+ return {
52
+ iosApps,
53
+ androidApps,
54
+ domains: [...new Set([...ios.domains, ...android.domains])],
55
+ findings,
56
+ };
57
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "deeplink-parity",
3
+ "version": "0.1.0",
4
+ "description": "Checks that what your mobile app declares about deep links matches what is actually hosted — across iOS and Android.",
5
+ "type": "module",
6
+ "bin": {
7
+ "deeplink-parity": "./dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "dev": "tsx src/cli.ts",
14
+ "build": "tsc",
15
+ "typecheck": "tsc --noEmit",
16
+ "test": "node --import tsx --test tests/*.test.ts",
17
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
18
+ },
19
+ "keywords": [
20
+ "deeplink",
21
+ "universal-links",
22
+ "app-links",
23
+ "apple-app-site-association",
24
+ "assetlinks",
25
+ "ios",
26
+ "android"
27
+ ],
28
+ "author": "Hosung Kang (https://github.com/camosss)",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/camosss/deeplink-parity.git"
33
+ },
34
+ "homepage": "https://github.com/camosss/deeplink-parity#readme",
35
+ "bugs": {
36
+ "url": "https://github.com/camosss/deeplink-parity/issues"
37
+ },
38
+ "dependencies": {
39
+ "fast-xml-parser": "^5.10.1",
40
+ "plist": "^3.1.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^22.10.0",
44
+ "@types/plist": "^3.0.5",
45
+ "tsx": "^4.19.0",
46
+ "typescript": "^5.7.0"
47
+ },
48
+ "engines": {
49
+ "node": ">=18"
50
+ }
51
+ }