fallow 2.81.0 → 2.82.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.
@@ -0,0 +1,573 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const crypto = require("node:crypto");
4
+ const fs = require("node:fs");
5
+ const os = require("node:os");
6
+ const path = require("node:path");
7
+
8
+ const {
9
+ ensureVerified,
10
+ SENTINEL_SCHEMA_VERSION,
11
+ VERIFY_LOG_ENV,
12
+ _resetWarningState,
13
+ } = require("./lazy-verify");
14
+ const { SENTINEL_FILENAME } = require("./sentinel-path");
15
+ const { _verifyWithKey, SKIP_ENV } = require("./verify-binary");
16
+
17
+ // ---- shared fixtures ------------------------------------------------------
18
+
19
+ function makeKeypair() {
20
+ const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
21
+ const spki = publicKey.export({ format: "der", type: "spki" });
22
+ const rawPub = spki.subarray(spki.length - 32);
23
+ return { privateKey, rawPub };
24
+ }
25
+
26
+ function ext() {
27
+ return process.platform === "win32" ? ".exe" : "";
28
+ }
29
+
30
+ function binaryNames() {
31
+ return [`fallow${ext()}`, `fallow-lsp${ext()}`, `fallow-mcp${ext()}`];
32
+ }
33
+
34
+ function computeDigestsForDir(dir) {
35
+ const out = {};
36
+ for (const base of binaryNames()) {
37
+ const full = path.join(dir, base);
38
+ out[base] = "sha256:" + crypto.createHash("sha256").update(fs.readFileSync(full)).digest("hex");
39
+ }
40
+ return out;
41
+ }
42
+
43
+ function mkPlatformDir(privateKey, options) {
44
+ const opts = options || {};
45
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fallow-lazy-test-"));
46
+ for (const base of binaryNames()) {
47
+ const binaryPath = path.join(dir, base);
48
+ const content = Buffer.from(`mock ${base}`);
49
+ fs.writeFileSync(binaryPath, content);
50
+ if (opts.skipSigFor === base) continue;
51
+ const sig = crypto.sign(null, content, privateKey);
52
+ if (opts.corruptSigFor === base) sig[0] ^= 0xff;
53
+ fs.writeFileSync(`${binaryPath}.sig`, sig);
54
+ }
55
+ fs.writeFileSync(
56
+ path.join(dir, "package.json"),
57
+ JSON.stringify({
58
+ name: opts.packageName || "@fallow-cli/test-platform",
59
+ version: opts.version || "2.81.0",
60
+ fallowDigests: opts.skipDigests ? undefined : computeDigestsForDir(dir),
61
+ }),
62
+ );
63
+ return dir;
64
+ }
65
+
66
+ function cleanup(dir) {
67
+ fs.rmSync(dir, { recursive: true, force: true });
68
+ }
69
+
70
+ function captureStderr(t) {
71
+ const lines = [];
72
+ const original = process.stderr.write.bind(process.stderr);
73
+ process.stderr.write = (chunk) => {
74
+ lines.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
75
+ return true;
76
+ };
77
+ t.after(() => {
78
+ process.stderr.write = original;
79
+ });
80
+ return { lines };
81
+ }
82
+
83
+ function setupCacheRoot(t) {
84
+ const cacheRoot = fs.mkdtempSync(path.join(os.tmpdir(), "fallow-lazy-cache-"));
85
+ t.after(() => cleanup(cacheRoot));
86
+ return cacheRoot;
87
+ }
88
+
89
+ function baseInput(dir, verifyFn, extras) {
90
+ return {
91
+ platformPkgDir: dir,
92
+ packageName: "@fallow-cli/test-platform",
93
+ manifestPath: path.join(dir, "package.json"),
94
+ verifyFn,
95
+ env: {},
96
+ platform: process.platform,
97
+ ...extras,
98
+ };
99
+ }
100
+
101
+ // ---- happy path: cache miss then cache hit --------------------------------
102
+
103
+ test("ensureVerified verifies on cache miss and writes the sentinel", (t) => {
104
+ _resetWarningState();
105
+ const { privateKey, rawPub } = makeKeypair();
106
+ const dir = mkPlatformDir(privateKey);
107
+ t.after(() => cleanup(dir));
108
+
109
+ const result = ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
110
+ assert.equal(result.ok, true);
111
+ assert.equal(result.cached, false);
112
+ assert.equal(result.sentinelPath, path.join(dir, SENTINEL_FILENAME));
113
+
114
+ // Sentinel file exists and validates
115
+ const sentinel = JSON.parse(fs.readFileSync(result.sentinelPath, "utf8"));
116
+ assert.equal(sentinel.schemaVersion, SENTINEL_SCHEMA_VERSION);
117
+ assert.equal(sentinel.packageVersion, "2.81.0");
118
+ assert.equal(sentinel.packageName, "@fallow-cli/test-platform");
119
+ assert.equal(Object.keys(sentinel.binaries).length, 3);
120
+ });
121
+
122
+ test("ensureVerified returns cached:true on a valid sentinel", (t) => {
123
+ _resetWarningState();
124
+ const { privateKey, rawPub } = makeKeypair();
125
+ const dir = mkPlatformDir(privateKey);
126
+ t.after(() => cleanup(dir));
127
+
128
+ // First call writes sentinel
129
+ ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
130
+
131
+ // Second call should hit cache (verifyFn must NOT be called)
132
+ let verifyCallCount = 0;
133
+ const result = ensureVerified(
134
+ baseInput(dir, (p) => {
135
+ verifyCallCount += 1;
136
+ return _verifyWithKey(p, rawPub);
137
+ }),
138
+ );
139
+ assert.equal(result.ok, true);
140
+ assert.equal(result.cached, true);
141
+ assert.equal(verifyCallCount, 0, "sig verify should NOT have run on a cache hit");
142
+ });
143
+
144
+ // ---- cache invalidation modes ---------------------------------------------
145
+
146
+ test("ensureVerified invalidates sentinel on mtime drift", (t) => {
147
+ _resetWarningState();
148
+ const { privateKey, rawPub } = makeKeypair();
149
+ const dir = mkPlatformDir(privateKey);
150
+ t.after(() => cleanup(dir));
151
+
152
+ ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
153
+
154
+ // Bump the mtime of one binary; sentinel should now be stale.
155
+ const newTime = new Date(Date.now() + 10_000);
156
+ fs.utimesSync(path.join(dir, `fallow${ext()}`), newTime, newTime);
157
+
158
+ let verifyCallCount = 0;
159
+ const result = ensureVerified(
160
+ baseInput(dir, (p) => {
161
+ verifyCallCount += 1;
162
+ return _verifyWithKey(p, rawPub);
163
+ }),
164
+ );
165
+ assert.equal(result.ok, true);
166
+ assert.equal(result.cached, false);
167
+ assert.equal(verifyCallCount, 3, "verify should rerun for all three binaries");
168
+ });
169
+
170
+ test("ensureVerified invalidates sentinel on packageVersion drift", (t) => {
171
+ _resetWarningState();
172
+ const { privateKey, rawPub } = makeKeypair();
173
+ const dir = mkPlatformDir(privateKey);
174
+ t.after(() => cleanup(dir));
175
+
176
+ ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
177
+
178
+ // Rewrite manifest with a different version.
179
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
180
+ manifest.version = "2.81.1";
181
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(manifest));
182
+
183
+ const result = ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
184
+ assert.equal(result.cached, false);
185
+ });
186
+
187
+ test("ensureVerified invalidates sentinel on packageName drift", (t) => {
188
+ _resetWarningState();
189
+ const { privateKey, rawPub } = makeKeypair();
190
+ const dir = mkPlatformDir(privateKey, { packageName: "@fallow-cli/x" });
191
+ t.after(() => cleanup(dir));
192
+
193
+ ensureVerified({
194
+ ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
195
+ packageName: "@fallow-cli/x",
196
+ });
197
+
198
+ // Now claim a different package name; sentinel becomes stale.
199
+ const result = ensureVerified({
200
+ ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
201
+ packageName: "@fallow-cli/y",
202
+ });
203
+ // Manifest still says @fallow-cli/x, so sentinel validates against manifest
204
+ // but the sentinel was originally written for x. Since we pass packageName=y
205
+ // for the cache lookup but the manifest still says x, the sentinel
206
+ // .packageName=x matches the manifest.name=x. We must rewrite manifest too
207
+ // to truly drift. Skip this case and instead force sentinel rewrite to
208
+ // have a different name:
209
+ fs.writeFileSync(
210
+ result.sentinelPath || path.join(dir, SENTINEL_FILENAME),
211
+ JSON.stringify({
212
+ schemaVersion: SENTINEL_SCHEMA_VERSION,
213
+ verifiedAt: new Date().toISOString(),
214
+ packageVersion: "2.81.0",
215
+ packageName: "@fallow-cli/wrong-name",
216
+ binaries: {
217
+ [`fallow${ext()}`]: { mtimeMs: fs.statSync(path.join(dir, `fallow${ext()}`)).mtimeMs },
218
+ [`fallow-lsp${ext()}`]: {
219
+ mtimeMs: fs.statSync(path.join(dir, `fallow-lsp${ext()}`)).mtimeMs,
220
+ },
221
+ [`fallow-mcp${ext()}`]: {
222
+ mtimeMs: fs.statSync(path.join(dir, `fallow-mcp${ext()}`)).mtimeMs,
223
+ },
224
+ },
225
+ }),
226
+ );
227
+
228
+ const result2 = ensureVerified({
229
+ ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
230
+ packageName: "@fallow-cli/x",
231
+ });
232
+ assert.equal(result2.cached, false);
233
+ });
234
+
235
+ test("ensureVerified invalidates sentinel on malformed JSON", (t) => {
236
+ _resetWarningState();
237
+ const { privateKey, rawPub } = makeKeypair();
238
+ const dir = mkPlatformDir(privateKey);
239
+ t.after(() => cleanup(dir));
240
+
241
+ ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
242
+ fs.writeFileSync(path.join(dir, SENTINEL_FILENAME), "not-json-at-all");
243
+
244
+ const result = ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
245
+ assert.equal(result.cached, false);
246
+ });
247
+
248
+ test("ensureVerified invalidates sentinel on schemaVersion drift", (t) => {
249
+ _resetWarningState();
250
+ const { privateKey, rawPub } = makeKeypair();
251
+ const dir = mkPlatformDir(privateKey);
252
+ t.after(() => cleanup(dir));
253
+
254
+ ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
255
+ const sentinelPath = path.join(dir, SENTINEL_FILENAME);
256
+ const data = JSON.parse(fs.readFileSync(sentinelPath, "utf8"));
257
+ data.schemaVersion = 999;
258
+ fs.writeFileSync(sentinelPath, JSON.stringify(data));
259
+
260
+ const result = ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
261
+ assert.equal(result.cached, false);
262
+ });
263
+
264
+ // ---- failure modes --------------------------------------------------------
265
+
266
+ test("ensureVerified returns sig-invalid on a tampered signature", (t) => {
267
+ _resetWarningState();
268
+ const { privateKey, rawPub } = makeKeypair();
269
+ const dir = mkPlatformDir(privateKey, { corruptSigFor: `fallow-lsp${ext()}` });
270
+ t.after(() => cleanup(dir));
271
+
272
+ const result = ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
273
+ assert.equal(result.ok, false);
274
+ assert.equal(result.code, "sig-invalid");
275
+ assert.match(result.binary, /fallow-lsp/);
276
+ // Sentinel must NOT have been written on failure
277
+ assert.equal(fs.existsSync(path.join(dir, SENTINEL_FILENAME)), false);
278
+ });
279
+
280
+ test("ensureVerified returns digest-unavailable on a pre-#597 manifest", (t) => {
281
+ _resetWarningState();
282
+ const { privateKey, rawPub } = makeKeypair();
283
+ const dir = mkPlatformDir(privateKey, { skipDigests: true });
284
+ t.after(() => cleanup(dir));
285
+
286
+ const result = ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
287
+ assert.equal(result.ok, false);
288
+ assert.equal(result.code, "digest-unavailable");
289
+ assert.match(result.message, /predates fallow 2\.78\.1/);
290
+ assert.match(result.message, new RegExp(SKIP_ENV));
291
+ });
292
+
293
+ // ---- cache-dir cascade ----------------------------------------------------
294
+
295
+ test("ensureVerified honors FALLOW_VERIFY_CACHE_DIR when platform pkg dir is non-writable", (t) => {
296
+ _resetWarningState();
297
+ if (process.platform === "win32") {
298
+ t.skip("Windows ACL chmod is not portable; covered by sentinel-path tests");
299
+ return;
300
+ }
301
+ const { privateKey, rawPub } = makeKeypair();
302
+ const dir = mkPlatformDir(privateKey);
303
+ const cacheRoot = setupCacheRoot(t);
304
+
305
+ fs.chmodSync(dir, 0o555);
306
+ try {
307
+ const result = ensureVerified({
308
+ ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
309
+ env: { FALLOW_VERIFY_CACHE_DIR: cacheRoot },
310
+ });
311
+ assert.equal(result.ok, true);
312
+ assert.equal(result.cached, false);
313
+ assert.match(result.sentinelPath, new RegExp(cacheRoot.replace(/\\/g, "\\\\")));
314
+ } finally {
315
+ fs.chmodSync(dir, 0o755);
316
+ cleanup(dir);
317
+ }
318
+ });
319
+
320
+ test("ensureVerified emits a single warning when sentinel write fails", (t) => {
321
+ _resetWarningState();
322
+ if (process.platform === "win32") {
323
+ t.skip("Windows ACL chmod is not portable");
324
+ return;
325
+ }
326
+ const { privateKey, rawPub } = makeKeypair();
327
+ const dir = mkPlatformDir(privateKey);
328
+ const stderr = captureStderr(t);
329
+
330
+ // Make platform pkg dir non-writable AND point FALLOW_VERIFY_CACHE_DIR at
331
+ // a non-existent path. resolveSentinelPath will still find the XDG / home
332
+ // fallback, but we can simulate "every cache location read-only" by giving
333
+ // it a writable dir that we then chmod down right before ensureVerified
334
+ // runs writeSentinel. Simpler: instead of fighting the cascade in env,
335
+ // simulate write failure via a non-writable FALLOW_VERIFY_CACHE_DIR that
336
+ // PASSES isWritable() but then has its perms revoked between resolve and
337
+ // write. Since that race is hard to script, we test the warn-once helper
338
+ // via a synthetic path that fails the rename atomically: pass a sentinel-
339
+ // chmod-locked dir.
340
+ //
341
+ // The portable shape: chmod the platform pkg dir read-only AND pass a
342
+ // FALLOW_VERIFY_CACHE_DIR that is a regular file (not a dir). isWritable
343
+ // returns false for both, so resolveSentinelPath falls through to XDG. If
344
+ // the user has no HOME (synthetic env), the cascade returns null. We can
345
+ // achieve this only by injecting both env.HOME='' AND env.XDG_CACHE_HOME=''
346
+ // AND ensuring os.homedir() is not consulted; ensureVerified does not
347
+ // accept a homedir override, so the warn-once path is unreachable via env
348
+ // alone on a machine with a real HOME. Skip this test on machines with a
349
+ // real homedir; the warn-once helper is otherwise covered by the
350
+ // "no-writable-location" branch in sentinel-path tests.
351
+ if (os.homedir() && os.homedir().length > 0) {
352
+ t.skip(
353
+ "warn-once-on-no-writable-cache requires homedir-override knob (covered by sentinel-path unit test)",
354
+ );
355
+ cleanup(dir);
356
+ return;
357
+ }
358
+
359
+ fs.chmodSync(dir, 0o555);
360
+ try {
361
+ const env = { HOME: "", XDG_CACHE_HOME: "" };
362
+ const result = ensureVerified({
363
+ ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
364
+ env,
365
+ });
366
+ if (result.sentinelPath !== null) {
367
+ t.diagnostic(`sentinel landed at ${result.sentinelPath} despite empty HOME; skipping`);
368
+ return;
369
+ }
370
+ const warnings = stderr.lines.filter((l) => l.includes("no writable cache location"));
371
+ assert.equal(warnings.length, 1);
372
+ // Second call: no new warning.
373
+ ensureVerified({ ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)), env });
374
+ const warnings2 = stderr.lines.filter((l) => l.includes("no writable cache location"));
375
+ assert.equal(warnings2.length, 1);
376
+ } finally {
377
+ fs.chmodSync(dir, 0o755);
378
+ cleanup(dir);
379
+ }
380
+ });
381
+
382
+ // ---- FALLOW_SKIP_BINARY_VERIFY -------------------------------------------
383
+
384
+ test("ensureVerified short-circuits when FALLOW_SKIP_BINARY_VERIFY is set", () => {
385
+ _resetWarningState();
386
+ const result = ensureVerified({
387
+ platformPkgDir: "/this/path/does/not/exist",
388
+ packageName: "@fallow-cli/x",
389
+ manifestPath: "/also/missing",
390
+ env: { [SKIP_ENV]: "1" },
391
+ });
392
+ assert.equal(result.ok, true);
393
+ assert.equal(result.skipped, true);
394
+ assert.match(result.reason, new RegExp(SKIP_ENV));
395
+ });
396
+
397
+ // ---- FALLOW_VERIFY_LOG ----------------------------------------------------
398
+
399
+ test("ensureVerified emits one stderr line per outcome when FALLOW_VERIFY_LOG=1", (t) => {
400
+ _resetWarningState();
401
+ const { privateKey, rawPub } = makeKeypair();
402
+ const dir = mkPlatformDir(privateKey);
403
+ t.after(() => cleanup(dir));
404
+
405
+ const stderr = captureStderr(t);
406
+ // First invocation: cache miss
407
+ ensureVerified({
408
+ ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
409
+ env: { FALLOW_VERIFY_LOG: "1" },
410
+ });
411
+ // Second invocation: cache hit
412
+ ensureVerified({
413
+ ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
414
+ env: { FALLOW_VERIFY_LOG: "1" },
415
+ });
416
+
417
+ const logs = stderr.lines.filter((l) => l.startsWith("fallow-verify "));
418
+ assert.equal(logs.length, 2);
419
+ assert.match(logs[0], /outcome=ok cache=miss/);
420
+ assert.match(logs[1], /outcome=ok cache=hit/);
421
+ });
422
+
423
+ test("ensureVerified does not log when FALLOW_VERIFY_LOG is unset", (t) => {
424
+ _resetWarningState();
425
+ const { privateKey, rawPub } = makeKeypair();
426
+ const dir = mkPlatformDir(privateKey);
427
+ t.after(() => cleanup(dir));
428
+
429
+ const stderr = captureStderr(t);
430
+ ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
431
+ const logs = stderr.lines.filter((l) => l.startsWith("fallow-verify "));
432
+ assert.equal(logs.length, 0);
433
+ });
434
+
435
+ // ---- concurrency ----------------------------------------------------------
436
+
437
+ test("ensureVerified is idempotent under concurrent first-runs", async (t) => {
438
+ _resetWarningState();
439
+ const { privateKey, rawPub } = makeKeypair();
440
+ const dir = mkPlatformDir(privateKey);
441
+ t.after(() => cleanup(dir));
442
+
443
+ const calls = await Promise.all([
444
+ Promise.resolve().then(() => ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)))),
445
+ Promise.resolve().then(() => ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)))),
446
+ Promise.resolve().then(() => ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)))),
447
+ ]);
448
+
449
+ for (const r of calls) assert.equal(r.ok, true);
450
+ // Sentinel exists and is valid JSON
451
+ const sentinel = JSON.parse(fs.readFileSync(path.join(dir, SENTINEL_FILENAME), "utf8"));
452
+ assert.equal(sentinel.schemaVersion, SENTINEL_SCHEMA_VERSION);
453
+ assert.equal(sentinel.packageName, "@fallow-cli/test-platform");
454
+
455
+ // No leftover .tmp files in the dir
456
+ const files = fs.readdirSync(dir);
457
+ const tmps = files.filter((f) => f.includes(".tmp"));
458
+ assert.deepEqual(tmps, [], "no leftover temp files from concurrent writes");
459
+ });
460
+
461
+ // ---- cross-install sentinel reuse (security regression test) ----------
462
+
463
+ test("ensureVerified rejects a sentinel written for a different install dir", (t) => {
464
+ _resetWarningState();
465
+ if (process.platform === "win32") {
466
+ t.skip("chmod-based read-only platform pkg dir is not portable on Windows");
467
+ return;
468
+ }
469
+ // Two installs of the same package + version. install A is clean and writes
470
+ // a sentinel to a shared cache; install B has a tampered binary at the same
471
+ // package name + version. B must NOT trust A's sentinel via cache hit even
472
+ // when the recorded mtimes happen to match B's binary mtimes.
473
+ const { privateKey, rawPub } = makeKeypair();
474
+ const installA = mkPlatformDir(privateKey);
475
+ const installB = mkPlatformDir(privateKey);
476
+ // Tamper install B's fallow binary AFTER mkPlatformDir wrote a valid sig
477
+ // for the original bytes (mkPlatformDir does not expose a corrupt-binary
478
+ // option, so simulate the attack by overwriting bytes here).
479
+ fs.writeFileSync(path.join(installB, `fallow${ext()}`), Buffer.from("tampered bytes"));
480
+ const sharedCache = fs.mkdtempSync(path.join(os.tmpdir(), "fallow-shared-cache-"));
481
+
482
+ // Force the shared-cache cascade by making both platform pkg dirs
483
+ // non-writable (simulates yarn PnP / Docker layered images / pnpm
484
+ // verify-store invariants).
485
+ fs.chmodSync(installA, 0o555);
486
+ fs.chmodSync(installB, 0o555);
487
+ t.after(() => {
488
+ fs.chmodSync(installA, 0o755);
489
+ fs.chmodSync(installB, 0o755);
490
+ cleanup(installA);
491
+ cleanup(installB);
492
+ cleanup(sharedCache);
493
+ });
494
+
495
+ // Install A: clean verify. Sentinel lands in the shared cache because the
496
+ // platform pkg dir is read-only.
497
+ const resultA = ensureVerified({
498
+ ...baseInput(installA, (p) => _verifyWithKey(p, rawPub)),
499
+ env: { FALLOW_VERIFY_CACHE_DIR: sharedCache },
500
+ });
501
+ assert.equal(resultA.ok, true);
502
+ assert.match(resultA.sentinelPath, new RegExp(sharedCache.replace(/\\/g, "\\\\")));
503
+
504
+ // Attacker on install B copies install A's mtimes onto B's binaries so the
505
+ // mtime pre-filter would have matched. With only mtime + name + version
506
+ // gates this would produce a cache hit and skip verify; the platformPkgDir
507
+ // + SHA-256 binding must prevent that.
508
+ for (const name of binaryNames()) {
509
+ const aStat = fs.statSync(path.join(installA, name));
510
+ fs.chmodSync(path.join(installB, name), 0o644);
511
+ fs.utimesSync(path.join(installB, name), aStat.atime, aStat.mtime);
512
+ }
513
+
514
+ let verifyCallCount = 0;
515
+ const resultB = ensureVerified({
516
+ ...baseInput(installB, (p) => {
517
+ verifyCallCount += 1;
518
+ return _verifyWithKey(p, rawPub);
519
+ }),
520
+ env: { FALLOW_VERIFY_CACHE_DIR: sharedCache },
521
+ });
522
+ assert.equal(resultB.ok, false);
523
+ assert.equal(resultB.code, "sig-invalid");
524
+ assert.ok(verifyCallCount > 0, "expected re-verify on cross-install sentinel read");
525
+ });
526
+
527
+ test("ensureVerified rejects a sentinel where bytes drift but mtime stays", (t) => {
528
+ _resetWarningState();
529
+ const { privateKey, rawPub } = makeKeypair();
530
+ const dir = mkPlatformDir(privateKey);
531
+ t.after(() => cleanup(dir));
532
+
533
+ // First invocation writes a sentinel with the clean SHA-256.
534
+ ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
535
+
536
+ // Tamper the binary in place AND restore the prior mtime, so the mtime
537
+ // pre-filter matches but the bytes do not.
538
+ const binPath = path.join(dir, `fallow${ext()}`);
539
+ const before = fs.statSync(binPath);
540
+ fs.writeFileSync(binPath, Buffer.from("tampered"));
541
+ fs.utimesSync(binPath, before.atime, before.mtime);
542
+
543
+ let verifyCallCount = 0;
544
+ const result = ensureVerified(
545
+ baseInput(dir, (p) => {
546
+ verifyCallCount += 1;
547
+ return _verifyWithKey(p, rawPub);
548
+ }),
549
+ );
550
+ assert.equal(result.ok, false);
551
+ assert.equal(result.code, "sig-invalid");
552
+ assert.ok(verifyCallCount > 0, "expected re-verify when bytes diverge from sentinel SHA");
553
+ });
554
+
555
+ // ---- FALLOW_SKIP_BINARY_VERIFY warning (regression test for documented contract) ----
556
+
557
+ test("ensureVerified warns once on stderr when FALLOW_SKIP_BINARY_VERIFY is set", (t) => {
558
+ _resetWarningState();
559
+ const stderr = captureStderr(t);
560
+ const env = { [SKIP_ENV]: "1" };
561
+ ensureVerified({ platformPkgDir: "/x", packageName: "@fallow-cli/y", manifestPath: "/z", env });
562
+ ensureVerified({ platformPkgDir: "/x", packageName: "@fallow-cli/y", manifestPath: "/z", env });
563
+ const warnings = stderr.lines.filter(
564
+ (l) => l.includes(`${SKIP_ENV} is set`) && l.includes("verification is skipped"),
565
+ );
566
+ assert.equal(warnings.length, 1, "warning should fire exactly once per process");
567
+ });
568
+
569
+ // ---- VERIFY_LOG_ENV export ------------------------------------------------
570
+
571
+ test("VERIFY_LOG_ENV is exported with the documented name", () => {
572
+ assert.equal(VERIFY_LOG_ENV, "FALLOW_VERIFY_LOG");
573
+ });
@@ -1,20 +1,20 @@
1
1
  function getPlatformPackage(platform, arch, libcFamily) {
2
- if (platform === 'win32') {
3
- if (arch === 'x64') return '@fallow-cli/win32-x64-msvc';
4
- if (arch === 'arm64') return '@fallow-cli/win32-arm64-msvc';
2
+ if (platform === "win32") {
3
+ if (arch === "x64") return "@fallow-cli/win32-x64-msvc";
4
+ if (arch === "arm64") return "@fallow-cli/win32-arm64-msvc";
5
5
  return null;
6
6
  }
7
7
 
8
- if (platform === 'darwin') {
9
- if (arch === 'x64' || arch === 'arm64') {
8
+ if (platform === "darwin") {
9
+ if (arch === "x64" || arch === "arm64") {
10
10
  return `@fallow-cli/darwin-${arch}`;
11
11
  }
12
12
  return null;
13
13
  }
14
14
 
15
- if (platform === 'linux') {
16
- const libc = libcFamily === 'musl' ? 'musl' : 'gnu';
17
- if (arch === 'x64' || arch === 'arm64') {
15
+ if (platform === "linux") {
16
+ const libc = libcFamily === "musl" ? "musl" : "gnu";
17
+ if (arch === "x64" || arch === "arm64") {
18
18
  return `@fallow-cli/linux-${arch}-${libc}`;
19
19
  }
20
20
  return null;
@@ -1,26 +1,26 @@
1
- const test = require('node:test');
2
- const assert = require('node:assert/strict');
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
3
 
4
- const { getPlatformPackage } = require('./platform-package');
4
+ const { getPlatformPackage } = require("./platform-package");
5
5
 
6
- test('maps Windows x64 and arm64 to MSVC packages', () => {
7
- assert.equal(getPlatformPackage('win32', 'x64'), '@fallow-cli/win32-x64-msvc');
8
- assert.equal(getPlatformPackage('win32', 'arm64'), '@fallow-cli/win32-arm64-msvc');
6
+ test("maps Windows x64 and arm64 to MSVC packages", () => {
7
+ assert.equal(getPlatformPackage("win32", "x64"), "@fallow-cli/win32-x64-msvc");
8
+ assert.equal(getPlatformPackage("win32", "arm64"), "@fallow-cli/win32-arm64-msvc");
9
9
  });
10
10
 
11
- test('maps Linux packages with libc awareness', () => {
12
- assert.equal(getPlatformPackage('linux', 'x64', 'gnu'), '@fallow-cli/linux-x64-gnu');
13
- assert.equal(getPlatformPackage('linux', 'arm64', 'musl'), '@fallow-cli/linux-arm64-musl');
14
- assert.equal(getPlatformPackage('linux', 'arm64'), '@fallow-cli/linux-arm64-gnu');
11
+ test("maps Linux packages with libc awareness", () => {
12
+ assert.equal(getPlatformPackage("linux", "x64", "gnu"), "@fallow-cli/linux-x64-gnu");
13
+ assert.equal(getPlatformPackage("linux", "arm64", "musl"), "@fallow-cli/linux-arm64-musl");
14
+ assert.equal(getPlatformPackage("linux", "arm64"), "@fallow-cli/linux-arm64-gnu");
15
15
  });
16
16
 
17
- test('maps macOS packages by architecture', () => {
18
- assert.equal(getPlatformPackage('darwin', 'x64'), '@fallow-cli/darwin-x64');
19
- assert.equal(getPlatformPackage('darwin', 'arm64'), '@fallow-cli/darwin-arm64');
17
+ test("maps macOS packages by architecture", () => {
18
+ assert.equal(getPlatformPackage("darwin", "x64"), "@fallow-cli/darwin-x64");
19
+ assert.equal(getPlatformPackage("darwin", "arm64"), "@fallow-cli/darwin-arm64");
20
20
  });
21
21
 
22
- test('returns null for unsupported targets', () => {
23
- assert.equal(getPlatformPackage('win32', 'ia32'), null);
24
- assert.equal(getPlatformPackage('linux', 'ppc64'), null);
25
- assert.equal(getPlatformPackage('freebsd', 'x64'), null);
22
+ test("returns null for unsupported targets", () => {
23
+ assert.equal(getPlatformPackage("win32", "ia32"), null);
24
+ assert.equal(getPlatformPackage("linux", "ppc64"), null);
25
+ assert.equal(getPlatformPackage("freebsd", "x64"), null);
26
26
  });