fallow 2.76.0 → 2.78.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fallow",
3
- "version": "2.76.0",
4
- "description": "Codebase intelligence for TypeScript and JavaScript. Finds unused code, duplication, circular dependencies, complexity hotspots, and architecture drift. Optional runtime intelligence layer (Fallow Runtime) adds production execution evidence. Rust-native, sub-second, 94 framework plugins.",
3
+ "version": "2.78.0",
4
+ "description": "Codebase intelligence for TypeScript and JavaScript. Finds unused code, duplication, circular dependencies, complexity hotspots, and architecture drift. Optional runtime intelligence layer (Fallow Runtime) adds production execution evidence. Rust-native, sub-second, 95 framework plugins.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -64,22 +64,23 @@
64
64
  ],
65
65
  "scripts": {
66
66
  "postinstall": "node scripts/postinstall.js",
67
- "test:platform": "node --test scripts/platform-package.test.js"
67
+ "test:platform": "node --test scripts/platform-package.test.js",
68
+ "test:verify": "node --test scripts/verify-binary.test.js"
68
69
  },
69
70
  "dependencies": {
70
71
  "detect-libc": "2.1.2"
71
72
  },
72
73
  "devDependencies": {
73
- "@tanstack/intent": "0.0.40"
74
+ "@tanstack/intent": "0.0.41"
74
75
  },
75
76
  "optionalDependencies": {
76
- "@fallow-cli/darwin-arm64": "2.76.0",
77
- "@fallow-cli/darwin-x64": "2.76.0",
78
- "@fallow-cli/linux-x64-gnu": "2.76.0",
79
- "@fallow-cli/linux-arm64-gnu": "2.76.0",
80
- "@fallow-cli/linux-x64-musl": "2.76.0",
81
- "@fallow-cli/linux-arm64-musl": "2.76.0",
82
- "@fallow-cli/win32-arm64-msvc": "2.76.0",
83
- "@fallow-cli/win32-x64-msvc": "2.76.0"
77
+ "@fallow-cli/darwin-arm64": "2.78.0",
78
+ "@fallow-cli/darwin-x64": "2.78.0",
79
+ "@fallow-cli/linux-x64-gnu": "2.78.0",
80
+ "@fallow-cli/linux-arm64-gnu": "2.78.0",
81
+ "@fallow-cli/linux-x64-musl": "2.78.0",
82
+ "@fallow-cli/linux-arm64-musl": "2.78.0",
83
+ "@fallow-cli/win32-arm64-msvc": "2.78.0",
84
+ "@fallow-cli/win32-x64-msvc": "2.78.0"
84
85
  }
85
86
  }
package/schema.json CHANGED
@@ -91,6 +91,13 @@
91
91
  "$ref": "#/$defs/IgnoreExportsUsedInFileConfig",
92
92
  "default": false
93
93
  },
94
+ "ignoreDecorators": {
95
+ "description": "Decorators that fallow should NOT treat as evidence of reflective use.\nMembers carrying only these decorators are checked for usage as if they\nwere undecorated. Members carrying any decorator NOT in this list stay\nskipped (frameworks like NestJS, Angular, TypeORM rely on reflection so\nthe conservative default is to keep skipping).\n\nMatching rule: entries containing `.` (e.g. `\"decorators.log\"`) match\nthe full dotted path of a decorator. Bare entries (e.g. `\"step\"` or\n`\"decorators\"`) match the leftmost segment; a bare `\"decorators\"` entry\nthus collapses every `@decorators.*` decorator. Both `\"@step\"` and\n`\"step\"` round-trip equivalently (a leading `@` is stripped before\nmatching).\n\nEntries that never match a decorator in the analyzed codebase produce\na one-time warning at end of run, mirroring the existing\n`usedClassMembers` warn-on-unmatched-pattern behavior. See issue #471.",
96
+ "type": "array",
97
+ "items": {
98
+ "type": "string"
99
+ }
100
+ },
94
101
  "usedClassMembers": {
95
102
  "description": "Class member method/property rules that should never be flagged as\nunused. Supports plain member names for global suppression and scoped\nobjects with `extends` / `implements` constraints for framework-invoked\nmethods that should only be suppressed on matching classes.",
96
103
  "type": "array",
@@ -160,6 +167,7 @@
160
167
  "type-only-dependencies": "warn",
161
168
  "test-only-dependencies": "warn",
162
169
  "circular-dependencies": "error",
170
+ "re-export-cycle": "warn",
163
171
  "boundary-violation": "error",
164
172
  "coverage-gaps": "off",
165
173
  "feature-flags": "off",
@@ -268,6 +276,10 @@
268
276
  "description": "Report unused exports in entry files instead of auto-marking them as\nused. Catches typos in framework exports (e.g. `meatdata` instead of\n`metadata`). The CLI flag `--include-entry-exports` (global) overrides\nthis when set; otherwise the config value is used.",
269
277
  "type": "boolean",
270
278
  "default": false
279
+ },
280
+ "cache": {
281
+ "description": "Incremental cache tuning. Today the only knob is `maxSizeMb`, which\ncaps the on-disk cache and triggers LRU eviction during save. See\n[`CacheConfig`].",
282
+ "$ref": "#/$defs/CacheConfig"
271
283
  }
272
284
  },
273
285
  "additionalProperties": false,
@@ -943,6 +955,10 @@
943
955
  "$ref": "#/$defs/Severity",
944
956
  "default": "error"
945
957
  },
958
+ "re-export-cycle": {
959
+ "$ref": "#/$defs/Severity",
960
+ "default": "warn"
961
+ },
946
962
  "boundary-violation": {
947
963
  "$ref": "#/$defs/Severity",
948
964
  "default": "error"
@@ -1126,7 +1142,7 @@
1126
1142
  "type": "object",
1127
1143
  "properties": {
1128
1144
  "sdkPatterns": {
1129
- "description": "Additional SDK call patterns to detect as feature flags.\nThese are merged with the built-in patterns (LaunchDarkly, Statsig, Unleash, GrowthBook).",
1145
+ "description": "Additional SDK call patterns to detect as feature flags.\nThese are merged with the built-in patterns for common providers\nincluding LaunchDarkly, Statsig, Unleash, GrowthBook, Split, PostHog,\nVercel Flags, ConfigCat, Flagsmith, Optimizely, and Eppo.",
1130
1146
  "type": "array",
1131
1147
  "items": {
1132
1148
  "$ref": "#/$defs/SdkPattern"
@@ -1440,6 +1456,16 @@
1440
1456
  }
1441
1457
  ]
1442
1458
  },
1459
+ "re-export-cycle": {
1460
+ "anyOf": [
1461
+ {
1462
+ "$ref": "#/$defs/Severity"
1463
+ },
1464
+ {
1465
+ "type": "null"
1466
+ }
1467
+ ]
1468
+ },
1443
1469
  "boundary-violation": {
1444
1470
  "anyOf": [
1445
1471
  {
@@ -1631,6 +1657,12 @@
1631
1657
  "minimum": 0,
1632
1658
  "default": 0
1633
1659
  },
1660
+ "reExportCycles": {
1661
+ "type": "integer",
1662
+ "format": "uint",
1663
+ "minimum": 0,
1664
+ "default": 0
1665
+ },
1634
1666
  "typeOnlyDependencies": {
1635
1667
  "type": "integer",
1636
1668
  "format": "uint",
@@ -1679,6 +1711,15 @@
1679
1711
  "string",
1680
1712
  "null"
1681
1713
  ]
1714
+ },
1715
+ "cacheMaxAgeDays": {
1716
+ "description": "Maximum age (in days since last reuse or fresh create) of a persistent\nreusable base-snapshot worktree cache entry. Older entries are removed\nat the top of the next `fallow audit` invocation. The env var\n`FALLOW_AUDIT_CACHE_MAX_AGE_DAYS` wins over this field. Unset on both\nsides defaults to 30 days. Setting either source to `0` disables the\nsweep entirely (escape hatch for CI runners that prune caches\nout-of-band). Invalid env var values (non-integer, negative) silently\nfall back to this field / default rather than failing the audit.",
1717
+ "type": [
1718
+ "integer",
1719
+ "null"
1720
+ ],
1721
+ "format": "uint32",
1722
+ "minimum": 0
1682
1723
  }
1683
1724
  }
1684
1725
  },
@@ -1696,6 +1737,22 @@
1696
1737
  "const": "all"
1697
1738
  }
1698
1739
  ]
1740
+ },
1741
+ "CacheConfig": {
1742
+ "description": "Incremental cache configuration.\n\nToday only `maxSizeMb` is exposed. The env var `FALLOW_CACHE_MAX_SIZE`\n(also in MB) wins over this field when both are set. The default cap is\n256 MB; values are interpreted as whole megabytes.",
1743
+ "type": "object",
1744
+ "properties": {
1745
+ "maxSizeMb": {
1746
+ "description": "Maximum on-disk cache size in megabytes. When the serialized cache\nexceeds 80% of this cap during save, the oldest entries are evicted\ndown to 60% of the cap. Default: 256 MB.",
1747
+ "type": [
1748
+ "integer",
1749
+ "null"
1750
+ ],
1751
+ "format": "uint32",
1752
+ "minimum": 0
1753
+ }
1754
+ },
1755
+ "additionalProperties": false
1699
1756
  }
1700
1757
  }
1701
1758
  }
@@ -1,4 +1,5 @@
1
1
  const { getPlatformPackage } = require('./platform-package');
2
+ const { verifyInstalled, SKIP_ENV } = require('./verify-binary');
2
3
 
3
4
  const pkg = (() => {
4
5
  if (process.platform !== 'linux') {
@@ -22,11 +23,41 @@ if (!pkg) {
22
23
  }
23
24
 
24
25
  try {
25
- require.resolve(pkg);
26
+ require.resolve(`${pkg}/package.json`);
26
27
  } catch {
27
28
  console.warn(
28
29
  `fallow: Platform package ${pkg} not installed. ` +
29
30
  `This may happen if you used --no-optional. ` +
30
31
  `Run 'npm install' to fix.`
31
32
  );
33
+ // Without the platform package there is nothing to verify; keep the existing
34
+ // soft-fail behavior for --no-optional installs.
35
+ process.exit(0);
36
+ }
37
+
38
+ async function main() {
39
+ const result = await verifyInstalled();
40
+ if (result.skipped) {
41
+ console.warn(
42
+ `fallow: binary verification skipped (${SKIP_ENV}=${process.env[SKIP_ENV]}). ` +
43
+ `Only set this when you are deliberately replacing the published binary. ` +
44
+ `See https://github.com/fallow-rs/fallow/blob/main/SECURITY.md for details.`
45
+ );
46
+ process.exit(0);
47
+ }
48
+ if (!result.ok) {
49
+ const where = result.binary ? ` ${result.binary}` : '';
50
+ console.error(
51
+ `fallow: binary verification failed${where} (${result.code}): ${result.message}. ` +
52
+ `This usually means the published platform package was tampered with. ` +
53
+ `See https://github.com/fallow-rs/fallow/blob/main/SECURITY.md for details. ` +
54
+ `Set ${SKIP_ENV}=1 only if you are deliberately replacing the binary.`
55
+ );
56
+ process.exit(1);
57
+ }
32
58
  }
59
+
60
+ main().catch((err) => {
61
+ console.error(`fallow: binary verification failed (internal-error): ${err.message}`);
62
+ process.exit(1);
63
+ });
@@ -0,0 +1,347 @@
1
+ // Ed25519 + SHA-256 binary verification for the fallow npm wrapper.
2
+ //
3
+ // Verifies each platform binary against a .sig file shipped alongside it in
4
+ // the @fallow-cli/<platform> package, then cross-checks the binary bytes
5
+ // against the SHA-256 digest published by GitHub Releases for the matching
6
+ // version/platform asset. The .sig is produced at release time by
7
+ // `.github/scripts/sign-binary.mjs` using the workflow's
8
+ // ED25519_BINARY_SIGNING_PRIVATE_KEY secret. The matching public key (32 raw
9
+ // bytes) is embedded below and is identical to the value already trusted by
10
+ // the VS Code extension at editors/vscode/src/download.ts:19-22.
11
+ //
12
+ // Triggered from scripts/postinstall.js and from the GitHub Action installer
13
+ // at action/scripts/install.sh. The escape hatch FALLOW_SKIP_BINARY_VERIFY=1
14
+ // is documented in SECURITY.md.
15
+ //
16
+ // No external dependencies: uses node:crypto and node:fs only. Refs #465.
17
+
18
+ const crypto = require('node:crypto');
19
+ const fs = require('node:fs');
20
+ const https = require('node:https');
21
+ const path = require('node:path');
22
+ const { getPlatformPackage } = require('./platform-package');
23
+
24
+ const GITHUB_REPO = 'fallow-rs/fallow';
25
+ const DIGEST_TIMEOUT_MS = 10000;
26
+
27
+ // 32-byte Ed25519 public key, identical to BINARY_SIGNING_PUBLIC_KEY in
28
+ // editors/vscode/src/download.ts:19-22 and to the ED25519_BINARY_SIGNING_PUBLIC_KEY
29
+ // repo variable on fallow-rs/fallow. Embedded rather than fetched so verification
30
+ // works offline and cannot be silently downgraded by tampering with the network
31
+ // path.
32
+ const EMBEDDED_PUBLIC_KEY = Buffer.from([
33
+ 131, 78, 111, 215, 115, 51, 230, 238, 223, 119, 147, 71, 199, 16, 172, 180, 3, 210, 216, 35,
34
+ 77, 85, 159, 94, 215, 200, 126, 85, 42, 222, 11, 209,
35
+ ]);
36
+
37
+ // SPKI DER header for Ed25519 (RFC 8410). 12 bytes prepended to a 32-byte raw
38
+ // public key produces a complete SPKI structure that node:crypto.createPublicKey
39
+ // accepts directly.
40
+ const ED25519_SPKI_HEADER = Buffer.from([
41
+ 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
42
+ ]);
43
+
44
+ const SKIP_ENV = 'FALLOW_SKIP_BINARY_VERIFY';
45
+
46
+ function buildPublicKey(rawPubKey) {
47
+ if (!Buffer.isBuffer(rawPubKey) || rawPubKey.length !== 32) {
48
+ throw new Error('expected 32-byte raw Ed25519 public key');
49
+ }
50
+ const spki = Buffer.concat([ED25519_SPKI_HEADER, rawPubKey]);
51
+ return crypto.createPublicKey({ key: spki, format: 'der', type: 'spki' });
52
+ }
53
+
54
+ function _verifyWithKey(binaryPath, rawPubKey) {
55
+ let binaryBytes;
56
+ try {
57
+ binaryBytes = fs.readFileSync(binaryPath);
58
+ } catch (err) {
59
+ if (err && err.code === 'ENOENT') {
60
+ return { ok: false, code: 'binary-missing', message: `binary not found at ${binaryPath}` };
61
+ }
62
+ return { ok: false, code: 'read-error', message: `cannot read binary at ${binaryPath}: ${err.message}` };
63
+ }
64
+
65
+ const sigPath = `${binaryPath}.sig`;
66
+ let signature;
67
+ try {
68
+ signature = fs.readFileSync(sigPath);
69
+ } catch (err) {
70
+ if (err && err.code === 'ENOENT') {
71
+ return { ok: false, code: 'sig-missing', message: `signature not found at ${sigPath}` };
72
+ }
73
+ return { ok: false, code: 'read-error', message: `cannot read signature at ${sigPath}: ${err.message}` };
74
+ }
75
+
76
+ if (signature.length !== 64) {
77
+ return { ok: false, code: 'sig-invalid', message: `signature at ${sigPath} has unexpected length ${signature.length} (want 64)` };
78
+ }
79
+
80
+ let publicKey;
81
+ try {
82
+ publicKey = buildPublicKey(rawPubKey);
83
+ } catch (err) {
84
+ return { ok: false, code: 'key-invalid', message: `cannot construct public key: ${err.message}` };
85
+ }
86
+
87
+ let valid;
88
+ try {
89
+ valid = crypto.verify(null, binaryBytes, publicKey, signature);
90
+ } catch (err) {
91
+ return { ok: false, code: 'sig-invalid', message: `crypto.verify threw: ${err.message}` };
92
+ }
93
+ if (!valid) {
94
+ return { ok: false, code: 'sig-invalid', message: `Ed25519 verification failed for ${binaryPath}` };
95
+ }
96
+ return { ok: true };
97
+ }
98
+
99
+ function verifyBinaryAt(binaryPath) {
100
+ return _verifyWithKey(binaryPath, EMBEDDED_PUBLIC_KEY);
101
+ }
102
+
103
+ function normalizeDigest(digest) {
104
+ if (typeof digest !== 'string') {
105
+ return null;
106
+ }
107
+ const lower = digest.trim().toLowerCase();
108
+ const value = lower.startsWith('sha256:') ? lower.slice('sha256:'.length) : lower;
109
+ return /^[0-9a-f]{64}$/.test(value) ? value : null;
110
+ }
111
+
112
+ function sha256Hex(binaryPath) {
113
+ try {
114
+ return { ok: true, digest: crypto.createHash('sha256').update(fs.readFileSync(binaryPath)).digest('hex') };
115
+ } catch (err) {
116
+ if (err && err.code === 'ENOENT') {
117
+ return { ok: false, code: 'binary-missing', message: `binary not found at ${binaryPath}` };
118
+ }
119
+ return { ok: false, code: 'read-error', message: `cannot read binary at ${binaryPath}: ${err.message}` };
120
+ }
121
+ }
122
+
123
+ function verifyDigestAt(binaryPath, expectedDigest) {
124
+ const normalized = normalizeDigest(expectedDigest);
125
+ if (!normalized) {
126
+ return { ok: false, code: 'digest-invalid', message: `invalid SHA-256 digest '${expectedDigest}'` };
127
+ }
128
+
129
+ const actual = sha256Hex(binaryPath);
130
+ if (!actual.ok) {
131
+ return actual;
132
+ }
133
+ if (actual.digest !== normalized) {
134
+ return {
135
+ ok: false,
136
+ code: 'digest-mismatch',
137
+ message: `SHA-256 digest mismatch for ${binaryPath}: got ${actual.digest}, want ${normalized}`,
138
+ };
139
+ }
140
+ return { ok: true };
141
+ }
142
+
143
+ function httpsJson(url, redirects = 0) {
144
+ return new Promise((resolve, reject) => {
145
+ const request = https.get(
146
+ url,
147
+ { headers: { 'User-Agent': 'fallow-binary-verifier' }, timeout: DIGEST_TIMEOUT_MS },
148
+ (response) => {
149
+ if (
150
+ response.statusCode &&
151
+ response.statusCode >= 300 &&
152
+ response.statusCode < 400 &&
153
+ response.headers.location &&
154
+ redirects < 5
155
+ ) {
156
+ response.resume();
157
+ httpsJson(response.headers.location, redirects + 1).then(resolve, reject);
158
+ return;
159
+ }
160
+
161
+ const chunks = [];
162
+ response.on('data', (chunk) => chunks.push(chunk));
163
+ response.on('end', () => {
164
+ const body = Buffer.concat(chunks).toString('utf8');
165
+ if (!response.statusCode || response.statusCode >= 400) {
166
+ reject(new Error(`GitHub release API returned HTTP ${response.statusCode || 'unknown'}: ${body.slice(0, 200)}`));
167
+ return;
168
+ }
169
+ try {
170
+ resolve(JSON.parse(body));
171
+ } catch (err) {
172
+ reject(new Error(`GitHub release API returned invalid JSON: ${err.message}`));
173
+ }
174
+ });
175
+ },
176
+ );
177
+ request.on('timeout', () => request.destroy(new Error(`timed out after ${DIGEST_TIMEOUT_MS}ms`)));
178
+ request.on('error', reject);
179
+ });
180
+ }
181
+
182
+ const releaseDigestCache = new Map();
183
+
184
+ async function fetchReleaseDigest(version, assetName) {
185
+ const key = version;
186
+ let release = releaseDigestCache.get(key);
187
+ if (!release) {
188
+ const url = `https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${version}`;
189
+ release = await httpsJson(url);
190
+ releaseDigestCache.set(key, release);
191
+ }
192
+ const asset = Array.isArray(release.assets)
193
+ ? release.assets.find((candidate) => candidate && candidate.name === assetName)
194
+ : null;
195
+ if (!asset) {
196
+ throw new Error(`release v${version} does not contain asset ${assetName}`);
197
+ }
198
+ const digest = normalizeDigest(asset.digest);
199
+ if (!digest) {
200
+ throw new Error(`release asset ${assetName} is missing a valid SHA-256 digest`);
201
+ }
202
+ return digest;
203
+ }
204
+
205
+ function platformPackageDir(pkg, resolveFrom) {
206
+ // require.resolve('<pkg>/package.json') is reliable across npm, pnpm, yarn,
207
+ // bun. It returns the absolute path to the package's package.json; the
208
+ // binaries sit next to it.
209
+ const options = resolveFrom ? { paths: [resolveFrom] } : undefined;
210
+ const manifestPath = require.resolve(`${pkg}/package.json`, options);
211
+ return { dir: path.dirname(manifestPath), manifestPath };
212
+ }
213
+
214
+ function binaryTargetsForPlatform(platformId) {
215
+ const isWindows = process.platform === 'win32';
216
+ const ext = isWindows ? '.exe' : '';
217
+ return [
218
+ { binary: `fallow${ext}`, asset: `fallow-${platformId}${ext}` },
219
+ { binary: `fallow-lsp${ext}`, asset: `fallow-lsp-${platformId}${ext}` },
220
+ { binary: `fallow-mcp${ext}`, asset: `fallow-mcp-${platformId}${ext}` },
221
+ ];
222
+ }
223
+
224
+ function isSkipRequested() {
225
+ const v = process.env[SKIP_ENV];
226
+ return v === '1' || v === 'true' || v === 'yes';
227
+ }
228
+
229
+ // Locate the platform package the wrapper would use at runtime and verify
230
+ // each of its three binaries. Returns the same result shape as
231
+ // verifyBinaryAt, with `binary` populated on failure so callers can produce
232
+ // a useful error.
233
+ //
234
+ // options:
235
+ // allowSkipEnv - if false, ignore FALLOW_SKIP_BINARY_VERIFY. Default true.
236
+ // dirOverride - absolute path to a directory containing the binaries.
237
+ // Skips platform-package resolution entirely. Test-only
238
+ // knob; production call sites must not pass it.
239
+ // verifyFn - function (binaryPath) -> result. Replaces verifyBinaryAt
240
+ // for tests that need to inject a non-production key.
241
+ // digestProvider - function ({ assetName, binaryPath, packageName, version })
242
+ // -> sha256 digest. Replaces GitHub Release API lookup in tests.
243
+ // resolveFrom - module resolution base for locating platform packages.
244
+ // The GitHub Action passes the global npm root so verifier
245
+ // code from the action checkout does not trust installed code.
246
+ async function verifyInstalled(options) {
247
+ const opts = options || {};
248
+ const skipEnvAllowed = opts.allowSkipEnv !== false;
249
+ if (skipEnvAllowed && isSkipRequested()) {
250
+ return { ok: true, skipped: true, reason: `${SKIP_ENV} is set` };
251
+ }
252
+
253
+ const verify = typeof opts.verifyFn === 'function' ? opts.verifyFn : verifyBinaryAt;
254
+ const digestProvider = typeof opts.digestProvider === 'function'
255
+ ? opts.digestProvider
256
+ : ({ assetName, version }) => fetchReleaseDigest(version, assetName);
257
+
258
+ let dir;
259
+ let manifestPath;
260
+ let pkg;
261
+ let version;
262
+ let platformId;
263
+ if (typeof opts.dirOverride === 'string' && opts.dirOverride.length > 0) {
264
+ dir = opts.dirOverride;
265
+ pkg = '<override>';
266
+ version = opts.version || '0.0.0';
267
+ platformId = opts.platformId || 'test-platform';
268
+ } else {
269
+ if (process.platform !== 'linux') {
270
+ pkg = getPlatformPackage(process.platform, process.arch);
271
+ } else {
272
+ let libcFamily;
273
+ try {
274
+ libcFamily = require('detect-libc').familySync();
275
+ } catch {
276
+ // detect-libc is a dependency, but tolerate its absence the same way
277
+ // postinstall.js already does.
278
+ libcFamily = undefined;
279
+ }
280
+ pkg = getPlatformPackage(process.platform, process.arch, libcFamily);
281
+ }
282
+
283
+ if (!pkg) {
284
+ return { ok: false, code: 'platform-unsupported', message: `no prebuilt binary for ${process.platform}-${process.arch}` };
285
+ }
286
+
287
+ try {
288
+ ({ dir, manifestPath } = platformPackageDir(pkg, opts.resolveFrom));
289
+ } catch (err) {
290
+ return { ok: false, code: 'platform-package-missing', message: `platform package ${pkg} not installed: ${err.message}`, package: pkg };
291
+ }
292
+
293
+ platformId = pkg.replace(/^@fallow-cli\//, '');
294
+ try {
295
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
296
+ version = manifest.version;
297
+ } catch (err) {
298
+ return { ok: false, code: 'manifest-invalid', message: `cannot read platform package manifest for ${pkg}: ${err.message}`, package: pkg };
299
+ }
300
+ if (typeof version !== 'string' || !version.trim()) {
301
+ return { ok: false, code: 'manifest-invalid', message: `platform package ${pkg} does not declare a version`, package: pkg };
302
+ }
303
+ }
304
+
305
+ for (const target of binaryTargetsForPlatform(platformId)) {
306
+ const binaryPath = path.join(dir, target.binary);
307
+ const result = verify(binaryPath);
308
+ if (!result.ok) {
309
+ return { ...result, binary: binaryPath, package: pkg };
310
+ }
311
+ let expectedDigest;
312
+ try {
313
+ expectedDigest = await digestProvider({
314
+ assetName: target.asset,
315
+ binaryPath,
316
+ packageName: pkg,
317
+ version,
318
+ });
319
+ } catch (err) {
320
+ return {
321
+ ok: false,
322
+ code: 'digest-unavailable',
323
+ message: `cannot load SHA-256 digest for ${target.asset}: ${err.message}`,
324
+ binary: binaryPath,
325
+ package: pkg,
326
+ };
327
+ }
328
+ const digestResult = verifyDigestAt(binaryPath, expectedDigest);
329
+ if (!digestResult.ok) {
330
+ return { ...digestResult, binary: binaryPath, package: pkg };
331
+ }
332
+ }
333
+ return { ok: true, package: pkg, version };
334
+ }
335
+
336
+ module.exports = {
337
+ verifyBinaryAt,
338
+ verifyDigestAt,
339
+ verifyInstalled,
340
+ _verifyWithKey,
341
+ fetchReleaseDigest,
342
+ normalizeDigest,
343
+ sha256Hex,
344
+ EMBEDDED_PUBLIC_KEY,
345
+ ED25519_SPKI_HEADER,
346
+ SKIP_ENV,
347
+ };