deeplink-parity 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -120,17 +120,40 @@ npx deeplink-parity . --well-known ./staging/well-known
120
120
 
121
121
  <br>
122
122
 
123
+ ## Native, Flutter and React Native
124
+
125
+ Whatever the app is written in, deep links are declared in the same two native files and
126
+ verified against the same two hosted files. The scan looks for those, so a Flutter or
127
+ React Native checkout works with no extra setup:
128
+
129
+ ```bash
130
+ npx deeplink-parity . # ios/ and android/ live in one repo
131
+ ```
132
+
133
+ **Expo is the exception.** `expo prebuild` generates `ios/` and `android/` at build time
134
+ and they are normally gitignored, so there is nothing to scan. Run the check after a
135
+ prebuild, or on CI after the prebuild step. Pointed at an un-prebuilt Expo project the
136
+ scan says so rather than reporting a clean run — a static `app.json` also lists the
137
+ domains it found, though a JavaScript config is never evaluated.
138
+
139
+ <br>
140
+
123
141
  ## Hardened against real projects
124
142
 
125
143
  Synthetic fixtures agree with whatever the author assumed. Real apps do not — so this was
126
- run against several open-source iOS and Android apps (Wikipedia, Mastodon, Bitwarden,
127
- DuckDuckGo), which turned up three bugs that fixtures never would have:
144
+ run against open-source apps across all four stacks (Wikipedia, Mastodon, Bitwarden,
145
+ DuckDuckGo, Bluesky, Open Food Facts). Every one of these came from that exercise, and
146
+ none would have surfaced against fixtures alone:
128
147
 
129
148
  - `applinks:*.example.com` is a valid wildcard declaration, and there is no such host to fetch
130
149
  - `myapp://callback` carries a host too, but a custom scheme is not an App Link
131
150
  - an entitlements file belongs to one target, so the app must not inherit the widget's bundle id
151
+ - a dev domain's assetlinks names the `applicationIdSuffix` variant, which is still our app
152
+ - an app can declare a domain per country, and firing every request at once caused the very
153
+ timeouts it then reported — requests are now pooled
154
+ - an Expo checkout has no native project to scan, which read as a clean run
132
155
 
133
- Each is now a regression test. A browser's `http`/`https` filters with no host are correctly
156
+ Each is a regression test. A browser's `http`/`https` filters with no host are correctly
134
157
  read as browser registration rather than deep links.
135
158
 
136
159
  <br>
package/dist/cli.js CHANGED
@@ -55,8 +55,18 @@ async function main() {
55
55
  return;
56
56
  }
57
57
  const source = wellKnown ? localSource(resolve(wellKnown)) : networkSource();
58
- const result = await run({ roots, source, sha256 });
59
- if (result.iosApps.length === 0 && result.androidApps.length === 0) {
58
+ const result = await run({
59
+ roots,
60
+ source,
61
+ sha256,
62
+ onDiscovered: (count) => {
63
+ // some apps declare a domain per country; say so before spending minutes on it
64
+ if (!wellKnown && count > 50) {
65
+ console.error(`Checking ${count} domains — requests are pooled, so this will take a while.`);
66
+ }
67
+ },
68
+ });
69
+ if (result.iosApps.length === 0 && result.androidApps.length === 0 && result.findings.length === 0) {
60
70
  console.error(`No app configuration declaring deep links was found in ${roots.join(', ')}`);
61
71
  console.error('Expected a .entitlements file with applinks:, or an AndroidManifest.xml with intent-filters.');
62
72
  process.exit(2);
@@ -0,0 +1,44 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { displayPath, walk } from './walk.js';
3
+ const CONFIG_FILES = new Set(['app.json', 'app.config.js', 'app.config.ts', 'app.config.json']);
4
+ /**
5
+ * Expo projects declare deep links in the Expo config rather than in the native files,
6
+ * and `expo prebuild` generates `ios/` and `android/` at build time — they are usually
7
+ * gitignored. Scanning such a checkout finds nothing, which would read as "all clear".
8
+ *
9
+ * Static JSON config is parsed directly. A JavaScript config is not evaluated: doing so
10
+ * means executing arbitrary code from the repository being inspected, which a checker
11
+ * should not do. Those projects are pointed at `expo prebuild` instead.
12
+ */
13
+ export async function detectExpo(root, foundNative) {
14
+ if (foundNative)
15
+ return [];
16
+ const configs = await walk(root, (n) => CONFIG_FILES.has(n));
17
+ if (configs.length === 0)
18
+ return [];
19
+ const findings = [];
20
+ for (const path of configs) {
21
+ const source = await readFile(path, 'utf8');
22
+ if (!/associatedDomains|intentFilters/.test(source))
23
+ continue;
24
+ const isStatic = path.endsWith('.json');
25
+ const domains = isStatic ? staticDomains(source) : [];
26
+ findings.push({
27
+ severity: 'warn',
28
+ rule: 'expo-config-only',
29
+ message: 'Deep links are declared in the Expo config and no native project was found',
30
+ detail: domains.length
31
+ ? `Declared: ${domains.join(', ')}. Run \`npx expo prebuild\` and scan again to verify them.`
32
+ : 'Run `npx expo prebuild` to generate ios/ and android/, then scan again. A JavaScript config is not evaluated.',
33
+ source: displayPath(path),
34
+ });
35
+ }
36
+ return findings;
37
+ }
38
+ /** Pull `applinks:` entries out of a static Expo config without executing it. */
39
+ function staticDomains(source) {
40
+ const domains = new Set();
41
+ for (const m of source.matchAll(/"applinks:([^"]+)"/g))
42
+ domains.add(m[1].split('?')[0]);
43
+ return [...domains];
44
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Run tasks with a bounded number in flight.
3
+ *
4
+ * Some apps declare a domain per country, and firing every request at once looks
5
+ * like an attack from the receiving end — it also causes the timeouts it then
6
+ * reports as findings. Results keep input order.
7
+ */
8
+ export async function mapLimit(items, limit, task) {
9
+ const results = new Array(items.length);
10
+ let next = 0;
11
+ const worker = async () => {
12
+ while (next < items.length) {
13
+ const index = next++;
14
+ results[index] = await task(items[index], index);
15
+ }
16
+ };
17
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
18
+ return results;
19
+ }
20
+ /** Concurrent requests per run. Low enough to stay polite on a shared host. */
21
+ export const FETCH_CONCURRENCY = 6;
@@ -1,6 +1,12 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  const TIMEOUT_MS = 10_000;
4
+ /**
5
+ * Identify the tool to the hosts being read. An operator seeing these requests in a log
6
+ * should be able to tell what they are and who to ask, rather than finding an anonymous
7
+ * Node default.
8
+ */
9
+ const USER_AGENT = 'deeplink-parity (+https://github.com/camosss/deeplink-parity)';
4
10
  export const AASA_FILE = 'apple-app-site-association';
5
11
  export const ASSETLINKS_FILE = 'assetlinks.json';
6
12
  export function aasaUrl(domain) {
@@ -20,7 +26,7 @@ async function fetchRaw(url) {
20
26
  const res = await fetch(url, {
21
27
  redirect: 'manual',
22
28
  signal: controller.signal,
23
- headers: { accept: 'application/json' },
29
+ headers: { accept: 'application/json', 'user-agent': USER_AGENT },
24
30
  });
25
31
  const redirected = res.status >= 300 && res.status < 400;
26
32
  return {
package/dist/run.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { discoverAndroid } from './discover/android.js';
2
+ import { detectExpo } from './discover/expo.js';
2
3
  import { discoverIos } from './discover/ios.js';
4
+ import { FETCH_CONCURRENCY, mapLimit } from './fetch/pool.js';
3
5
  import { checkAndroidHost, checkAndroidUnresolved } from './rules/android.js';
4
6
  import { checkCrossPlatform } from './rules/cross.js';
5
7
  import { aasaPaths, checkIosDomain } from './rules/ios.js';
@@ -7,13 +9,22 @@ import { isWildcardDomain, wildcardFinding } from './rules/wildcard.js';
7
9
  function emptyView() {
8
10
  return { domains: new Set(), paths: new Map() };
9
11
  }
10
- export async function run({ roots, source, sha256 }) {
12
+ export async function run({ roots, source, sha256, onDiscovered }) {
11
13
  const discovered = await Promise.all(roots.map(async (root) => Promise.all([discoverIos(root), discoverAndroid(root)])));
12
14
  const iosApps = discovered.flatMap(([ios]) => ios);
13
15
  const androidApps = discovered.flatMap(([, android]) => android);
14
16
  const findings = [];
17
+ // an Expo checkout has no native project committed; say so rather than report nothing
18
+ if (iosApps.length === 0 && androidApps.length === 0) {
19
+ for (const root of roots)
20
+ findings.push(...(await detectExpo(root, false)));
21
+ }
15
22
  const ios = emptyView();
16
23
  const android = emptyView();
24
+ onDiscovered?.(new Set([
25
+ ...iosApps.flatMap((a) => a.domains),
26
+ ...androidApps.flatMap((a) => a.hosts.map((h) => h.host)),
27
+ ]).size);
17
28
  for (const app of iosApps) {
18
29
  // the same domain can be declared by several targets; fetch it once
19
30
  const fresh = app.domains.filter((d) => !ios.domains.has(d));
@@ -21,7 +32,7 @@ export async function run({ roots, source, sha256 }) {
21
32
  const wildcards = fresh.filter(isWildcardDomain);
22
33
  wildcards.forEach((d) => findings.push(wildcardFinding(d, app.entitlementsPath)));
23
34
  const pending = fresh.filter((d) => !isWildcardDomain(d));
24
- const results = await Promise.all(pending.map((d) => source.aasa(d)));
35
+ const results = await mapLimit(pending, FETCH_CONCURRENCY, (d) => source.aasa(d));
25
36
  pending.forEach((domain, i) => {
26
37
  const { findings: domainFindings, aasa } = checkIosDomain(app, domain, results[i]);
27
38
  findings.push(...domainFindings);
@@ -41,9 +52,8 @@ export async function run({ roots, source, sha256 }) {
41
52
  .filter((h) => isWildcardDomain(h.host))
42
53
  .forEach((h) => findings.push(wildcardFinding(h.host, app.manifestPath)));
43
54
  const pending = fresh.filter((h) => !isWildcardDomain(h.host));
44
- const results = await Promise.all(
45
55
  // an unverified host is never checked by the system, so there is nothing to fetch
46
- pending.map((h) => (h.autoVerify ? source.assetlinks(h.host) : Promise.resolve(null))));
56
+ const results = await mapLimit(pending, FETCH_CONCURRENCY, (h) => h.autoVerify ? source.assetlinks(h.host) : Promise.resolve(null));
47
57
  pending.forEach((entry, i) => {
48
58
  const res = results[i] ?? { url: '', ok: false, redirected: false };
49
59
  findings.push(...checkAndroidHost(app, entry, res, { sha256 }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deeplink-parity",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Checks that what your mobile app declares about deep links matches what is actually hosted — across iOS and Android.",
5
5
  "type": "module",
6
6
  "bin": {