fallow 2.78.0 → 2.78.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fallow",
3
- "version": "2.78.0",
3
+ "version": "2.78.1",
4
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": {
@@ -74,13 +74,13 @@
74
74
  "@tanstack/intent": "0.0.41"
75
75
  },
76
76
  "optionalDependencies": {
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"
77
+ "@fallow-cli/darwin-arm64": "2.78.1",
78
+ "@fallow-cli/darwin-x64": "2.78.1",
79
+ "@fallow-cli/linux-x64-gnu": "2.78.1",
80
+ "@fallow-cli/linux-arm64-gnu": "2.78.1",
81
+ "@fallow-cli/linux-x64-musl": "2.78.1",
82
+ "@fallow-cli/linux-arm64-musl": "2.78.1",
83
+ "@fallow-cli/win32-arm64-msvc": "2.78.1",
84
+ "@fallow-cli/win32-x64-msvc": "2.78.1"
85
85
  }
86
86
  }
@@ -2,18 +2,27 @@
2
2
  //
3
3
  // Verifies each platform binary against a .sig file shipped alongside it in
4
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
5
+ // against an expected SHA-256 digest. The .sig is produced at release time
6
+ // by `.github/scripts/sign-binary.mjs` using the workflow's
8
7
  // ED25519_BINARY_SIGNING_PRIVATE_KEY secret. The matching public key (32 raw
9
8
  // bytes) is embedded below and is identical to the value already trusted by
10
9
  // the VS Code extension at editors/vscode/src/download.ts:19-22.
11
10
  //
11
+ // SHA-256 digest source (in order of preference, refs #465 and #597):
12
+ // 1. `fallowDigests` field in the platform package's package.json, written
13
+ // at release time by the `npm-prep` job. This is the steady-state path:
14
+ // no network traffic, immune to GitHub API rate limits.
15
+ // 2. Fallback: GitHub Release asset digest via the public REST API. Kept
16
+ // for backwards compatibility with platform packages published before
17
+ // #597 that lack the embedded field. Shared-IP CI runners (Buildkite,
18
+ // pooled GHA runners) can exceed the 60 req/hr unauthenticated limit;
19
+ // that failure mode is what motivated #597.
20
+ //
12
21
  // Triggered from scripts/postinstall.js and from the GitHub Action installer
13
22
  // at action/scripts/install.sh. The escape hatch FALLOW_SKIP_BINARY_VERIFY=1
14
23
  // is documented in SECURITY.md.
15
24
  //
16
- // No external dependencies: uses node:crypto and node:fs only. Refs #465.
25
+ // No external dependencies: uses node:crypto and node:fs only.
17
26
 
18
27
  const crypto = require('node:crypto');
19
28
  const fs = require('node:fs');
@@ -211,6 +220,31 @@ function platformPackageDir(pkg, resolveFrom) {
211
220
  return { dir: path.dirname(manifestPath), manifestPath };
212
221
  }
213
222
 
223
+ // Read the SHA-256 digest for a binary embedded in the platform package's
224
+ // package.json (written by the npm-prep job at release time as
225
+ // `fallowDigests[<filename>]`). Returns the normalized digest hex or null
226
+ // when the manifest does not exist, cannot be parsed, lacks the field, or
227
+ // the value is malformed. Refs #597.
228
+ function readEmbeddedDigest(manifestPath, binaryFileName) {
229
+ if (typeof manifestPath !== 'string' || manifestPath.length === 0) {
230
+ return null;
231
+ }
232
+ let manifest;
233
+ try {
234
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
235
+ } catch {
236
+ return null;
237
+ }
238
+ if (!manifest || typeof manifest !== 'object') {
239
+ return null;
240
+ }
241
+ const digests = manifest.fallowDigests;
242
+ if (!digests || typeof digests !== 'object') {
243
+ return null;
244
+ }
245
+ return normalizeDigest(digests[binaryFileName]);
246
+ }
247
+
214
248
  function binaryTargetsForPlatform(platformId) {
215
249
  const isWindows = process.platform === 'win32';
216
250
  const ext = isWindows ? '.exe' : '';
@@ -265,6 +299,9 @@ async function verifyInstalled(options) {
265
299
  pkg = '<override>';
266
300
  version = opts.version || '0.0.0';
267
301
  platformId = opts.platformId || 'test-platform';
302
+ // Lets tests drop a package.json next to the binaries to exercise the
303
+ // embedded-digest path. readEmbeddedDigest returns null when missing.
304
+ manifestPath = path.join(dir, 'package.json');
268
305
  } else {
269
306
  if (process.platform !== 'linux') {
270
307
  pkg = getPlatformPackage(process.platform, process.arch);
@@ -308,22 +345,33 @@ async function verifyInstalled(options) {
308
345
  if (!result.ok) {
309
346
  return { ...result, binary: binaryPath, package: pkg };
310
347
  }
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
- };
348
+ // Prefer the digest embedded in the platform package's package.json
349
+ // (written at release time by `npm-prep`). Falling back to the GitHub
350
+ // release API only when no embedded digest is present preserves
351
+ // backwards compatibility with platform packages published before #597.
352
+ // The shared-IP CI rate-limit failure mode that motivated #597 only
353
+ // affects the fallback path, so the steady-state install path is now
354
+ // fully offline.
355
+ let expectedDigest = manifestPath
356
+ ? readEmbeddedDigest(manifestPath, target.binary)
357
+ : null;
358
+ if (!expectedDigest) {
359
+ try {
360
+ expectedDigest = await digestProvider({
361
+ assetName: target.asset,
362
+ binaryPath,
363
+ packageName: pkg,
364
+ version,
365
+ });
366
+ } catch (err) {
367
+ return {
368
+ ok: false,
369
+ code: 'digest-unavailable',
370
+ message: `cannot load SHA-256 digest for ${target.asset}: ${err.message}`,
371
+ binary: binaryPath,
372
+ package: pkg,
373
+ };
374
+ }
327
375
  }
328
376
  const digestResult = verifyDigestAt(binaryPath, expectedDigest);
329
377
  if (!digestResult.ok) {
@@ -340,6 +388,7 @@ module.exports = {
340
388
  _verifyWithKey,
341
389
  fetchReleaseDigest,
342
390
  normalizeDigest,
391
+ readEmbeddedDigest,
343
392
  sha256Hex,
344
393
  EMBEDDED_PUBLIC_KEY,
345
394
  ED25519_SPKI_HEADER,
@@ -12,6 +12,7 @@ const {
12
12
  verifyInstalled,
13
13
  sha256Hex,
14
14
  normalizeDigest,
15
+ readEmbeddedDigest,
15
16
  EMBEDDED_PUBLIC_KEY,
16
17
  ED25519_SPKI_HEADER,
17
18
  SKIP_ENV,
@@ -381,6 +382,141 @@ test('verifyInstalled honors FALLOW_SKIP_BINARY_VERIFY', async (t) => {
381
382
  assert.equal(result.skipped, true);
382
383
  });
383
384
 
385
+ function computeDigestsForDir(dir) {
386
+ const ext = process.platform === 'win32' ? '.exe' : '';
387
+ const out = {};
388
+ for (const base of ['fallow', 'fallow-lsp', 'fallow-mcp']) {
389
+ const fileName = `${base}${ext}`;
390
+ const full = path.join(dir, fileName);
391
+ out[fileName] = 'sha256:' + crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex');
392
+ }
393
+ return out;
394
+ }
395
+
396
+ function writeManifest(dir, body) {
397
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(body));
398
+ }
399
+
400
+ test('readEmbeddedDigest returns null when manifest is missing', () => {
401
+ assert.equal(readEmbeddedDigest('/does/not/exist/package.json', 'fallow'), null);
402
+ });
403
+
404
+ test('readEmbeddedDigest returns null when fallowDigests field is absent', (t) => {
405
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fallow-vbtest-emb-'));
406
+ t.after(() => cleanup(dir));
407
+ writeManifest(dir, { name: '@fallow-cli/x', version: '1.0.0' });
408
+ assert.equal(readEmbeddedDigest(path.join(dir, 'package.json'), 'fallow'), null);
409
+ });
410
+
411
+ test('readEmbeddedDigest returns null when the per-binary entry is malformed', (t) => {
412
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fallow-vbtest-emb-'));
413
+ t.after(() => cleanup(dir));
414
+ writeManifest(dir, {
415
+ name: '@fallow-cli/x',
416
+ version: '1.0.0',
417
+ fallowDigests: { fallow: 'not-a-real-digest' },
418
+ });
419
+ assert.equal(readEmbeddedDigest(path.join(dir, 'package.json'), 'fallow'), null);
420
+ });
421
+
422
+ test('readEmbeddedDigest returns normalized hex for a valid sha256: prefixed entry', (t) => {
423
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fallow-vbtest-emb-'));
424
+ t.after(() => cleanup(dir));
425
+ const hex = 'a'.repeat(64);
426
+ writeManifest(dir, {
427
+ name: '@fallow-cli/x',
428
+ version: '1.0.0',
429
+ fallowDigests: { fallow: 'sha256:' + hex },
430
+ });
431
+ assert.equal(readEmbeddedDigest(path.join(dir, 'package.json'), 'fallow'), hex);
432
+ });
433
+
434
+ test('verifyInstalled uses the embedded digest without calling the provider', async (t) => {
435
+ const { privateKey, rawPub } = makeKeypair();
436
+ const dir = makePlatformDir(privateKey);
437
+ t.after(() => cleanup(dir));
438
+ writeManifest(dir, {
439
+ name: '@fallow-cli/x',
440
+ version: '1.0.0',
441
+ fallowDigests: computeDigestsForDir(dir),
442
+ });
443
+ let providerCalls = 0;
444
+ const result = await verifyInstalled({
445
+ dirOverride: dir,
446
+ verifyFn: (p) => _verifyWithKey(p, rawPub),
447
+ digestProvider: () => {
448
+ providerCalls += 1;
449
+ return Promise.reject(new Error('provider should not be called'));
450
+ },
451
+ });
452
+ assert.equal(result.ok, true, JSON.stringify(result));
453
+ assert.equal(providerCalls, 0);
454
+ });
455
+
456
+ test('verifyInstalled falls back to the provider when the embedded digest is missing', async (t) => {
457
+ const { privateKey, rawPub } = makeKeypair();
458
+ const dir = makePlatformDir(privateKey);
459
+ t.after(() => cleanup(dir));
460
+ // No package.json at all; legacy platform package shape.
461
+ let providerCalls = 0;
462
+ const result = await verifyInstalled({
463
+ dirOverride: dir,
464
+ verifyFn: (p) => _verifyWithKey(p, rawPub),
465
+ digestProvider: ({ binaryPath }) => {
466
+ providerCalls += 1;
467
+ return Promise.resolve('sha256:' + crypto.createHash('sha256').update(fs.readFileSync(binaryPath)).digest('hex'));
468
+ },
469
+ });
470
+ assert.equal(result.ok, true);
471
+ assert.equal(providerCalls, 3);
472
+ });
473
+
474
+ test('verifyInstalled falls back to the provider when fallowDigests is partial / malformed', async (t) => {
475
+ const { privateKey, rawPub } = makeKeypair();
476
+ const dir = makePlatformDir(privateKey);
477
+ t.after(() => cleanup(dir));
478
+ writeManifest(dir, {
479
+ name: '@fallow-cli/x',
480
+ version: '1.0.0',
481
+ fallowDigests: { fallow: 'not-a-real-digest' },
482
+ });
483
+ let providerCalls = 0;
484
+ const result = await verifyInstalled({
485
+ dirOverride: dir,
486
+ verifyFn: (p) => _verifyWithKey(p, rawPub),
487
+ digestProvider: ({ binaryPath }) => {
488
+ providerCalls += 1;
489
+ return Promise.resolve('sha256:' + crypto.createHash('sha256').update(fs.readFileSync(binaryPath)).digest('hex'));
490
+ },
491
+ });
492
+ assert.equal(result.ok, true);
493
+ // One call per binary because all three entries fail to normalize.
494
+ assert.equal(providerCalls, 3);
495
+ });
496
+
497
+ test('verifyInstalled returns digest-mismatch when the embedded digest disagrees with the binary', async (t) => {
498
+ const { privateKey, rawPub } = makeKeypair();
499
+ const dir = makePlatformDir(privateKey);
500
+ t.after(() => cleanup(dir));
501
+ const ext = process.platform === 'win32' ? '.exe' : '';
502
+ writeManifest(dir, {
503
+ name: '@fallow-cli/x',
504
+ version: '1.0.0',
505
+ fallowDigests: {
506
+ [`fallow${ext}`]: 'sha256:' + 'a'.repeat(64),
507
+ [`fallow-lsp${ext}`]: 'sha256:' + 'a'.repeat(64),
508
+ [`fallow-mcp${ext}`]: 'sha256:' + 'a'.repeat(64),
509
+ },
510
+ });
511
+ const result = await verifyInstalled({
512
+ dirOverride: dir,
513
+ verifyFn: (p) => _verifyWithKey(p, rawPub),
514
+ digestProvider: () => Promise.reject(new Error('should not be reached')),
515
+ });
516
+ assert.equal(result.ok, false);
517
+ assert.equal(result.code, 'digest-mismatch');
518
+ });
519
+
384
520
  test('verifyInstalled ignores skip env when allowSkipEnv is false', async (t) => {
385
521
  const previous = process.env[SKIP_ENV];
386
522
  process.env[SKIP_ENV] = '1';