fallow 3.36.0 → 3.37.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.
@@ -3557,6 +3557,14 @@ total_issues: number
3557
3557
  * Prop chains whose saved effective severity explicitly enforces error policy.
3558
3558
  */
3559
3559
  prop_drilling_chains?: number
3560
+ /**
3561
+ * Thin wrappers whose saved effective severity explicitly enforces error policy.
3562
+ */
3563
+ thin_wrappers?: number
3564
+ /**
3565
+ * Duplicate prop shapes whose saved effective severity explicitly enforces error policy.
3566
+ */
3567
+ duplicate_prop_shapes?: number
3560
3568
  /**
3561
3569
  * Unused source files.
3562
3570
  */
@@ -6948,11 +6956,11 @@ actions: IssueAction[]
6948
6956
  */
6949
6957
  introduced?: (AuditIntroduced | null)
6950
6958
  /**
6951
- * Rule severity of this finding. This type never gates the run, so the
6952
- * value does not change the exit code. `fallow report --from` reads it
6953
- * for the SARIF level, so the level does not depend on the config at
6954
- * render time. Absent in output from older versions. Not part of the
6955
- * finding identity, baseline keys or fingerprints.
6959
+ * Rule severity of this finding. Explicit errors enforce analysis gates;
6960
+ * warnings stay advisory, including under strict flags. Saved reports
6961
+ * retain this value for CI levels without the original config. Absent
6962
+ * in output from older versions. Not part of finding identity, baseline
6963
+ * keys or fingerprints.
6956
6964
  */
6957
6965
  effective_severity?: (EffectiveSeverity | null)
6958
6966
  }
@@ -7014,11 +7022,11 @@ actions: IssueAction[]
7014
7022
  */
7015
7023
  introduced?: (AuditIntroduced | null)
7016
7024
  /**
7017
- * Rule severity of this finding. This type never gates the run, so the
7018
- * value does not change the exit code. `fallow report --from` reads it
7019
- * for the SARIF level, so the level does not depend on the config at
7020
- * render time. Absent in output from older versions. Not part of the
7021
- * finding identity, baseline keys or fingerprints.
7025
+ * Rule severity of this finding. Explicit errors enforce analysis gates;
7026
+ * warnings stay advisory, including under strict flags. Saved reports
7027
+ * retain this value for CI levels without the original config. Absent
7028
+ * in output from older versions. Not part of finding identity, baseline
7029
+ * keys or fingerprints.
7022
7030
  */
7023
7031
  effective_severity?: (EffectiveSeverity | null)
7024
7032
  }
@@ -19914,6 +19922,10 @@ runner?: (MutationRunnerOutput | null)
19914
19922
  * started.
19915
19923
  */
19916
19924
  cache?: (MutationCacheOutput | null)
19925
+ /**
19926
+ * The resume facts. Absent without `--resume`.
19927
+ */
19928
+ resume?: (MutationResumeOutput | null)
19917
19929
  summary: MutationSummary
19918
19930
  /**
19919
19931
  * Each mutant, ranked mutants first.
@@ -20048,6 +20060,36 @@ stored: number
20048
20060
  */
20049
20061
  not_keyed: string[]
20050
20062
  }
20063
+ /**
20064
+ * The resume facts of a `--resume` run.
20065
+ */
20066
+ export interface MutationResumeOutput {
20067
+ /**
20068
+ * The path of the outcome journal, relative to the project root. When
20069
+ * the cache directory is outside the root, the path is absolute: the
20070
+ * report has no other cache path, so no other rule applies. The file
20071
+ * stays only when the run did not complete. A run with
20072
+ * `completion.status: complete` removes it.
20073
+ */
20074
+ journal: string
20075
+ /**
20076
+ * The mutants whose status came from the journal. They did not run.
20077
+ */
20078
+ reused: number
20079
+ /**
20080
+ * The journal results whose key does not match this run, because an
20081
+ * input of the mutant changed. These results were not reused from the
20082
+ * journal. The mutant ran again, or its status came from the result
20083
+ * cache.
20084
+ */
20085
+ rejected: number
20086
+ /**
20087
+ * Why the run did not read a journal and started from the beginning,
20088
+ * for example because no journal exists. Absent when the run read the
20089
+ * journal.
20090
+ */
20091
+ note?: (string | null)
20092
+ }
20051
20093
  /**
20052
20094
  * The totals of a run.
20053
20095
  */
@@ -20173,6 +20215,11 @@ attempts?: MutantAttempt[]
20173
20215
  * The mutant did not run in this run. `--no-cache` runs each mutant.
20174
20216
  */
20175
20217
  cached?: boolean
20218
+ /**
20219
+ * True when the status comes from the journal of an interrupted run
20220
+ * (`--resume`). The mutant did not run in this run.
20221
+ */
20222
+ resumed?: boolean
20176
20223
  /**
20177
20224
  * The TypeScript diagnostic codes of the new errors of a
20178
20225
  * `compile_error` mutant, sorted, at most 8.
@@ -20355,7 +20402,8 @@ changed_paths: string[]
20355
20402
  */
20356
20403
  export interface MutationRunIssue {
20357
20404
  /**
20358
- * A stable kebab-case code: `runtime-error`, `static-map-miss`,
20405
+ * A stable kebab-case code: `cache-write-failed`,
20406
+ * `journal-write-failed`, `runtime-error`, `static-map-miss`,
20359
20407
  * `test-files-not-discovered`, `typecheck-failed`, `worktree-changed`
20360
20408
  * or `worktree-check-failed`.
20361
20409
  */
@@ -1,564 +0,0 @@
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 { ensureVerified, SENTINEL_SCHEMA_VERSION, _resetWarningState } = require("./lazy-verify");
9
- const { SENTINEL_FILENAME } = require("./sentinel-path");
10
- const { _verifyWithKey, binaryTargetsForPlatform, SKIP_ENV } = require("./verify-binary");
11
-
12
- // ---- shared fixtures ------------------------------------------------------
13
-
14
- const DEFAULT_PLATFORM = "linux";
15
- const DEFAULT_PACKAGE_NAME = "@fallow-cli/test-platform";
16
-
17
- function makeKeypair() {
18
- const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
19
- const spki = publicKey.export({ format: "der", type: "spki" });
20
- const rawPub = spki.subarray(spki.length - 32);
21
- return { privateKey, rawPub };
22
- }
23
-
24
- function packageNameForPlatform(platform) {
25
- return platform === "win32" ? "@fallow-cli/win32-x64-msvc" : DEFAULT_PACKAGE_NAME;
26
- }
27
-
28
- function binaryNames(platform) {
29
- const ext = platform === "win32" ? ".exe" : "";
30
- return [`fallow${ext}`, `fallow-similar-code${ext}`];
31
- }
32
-
33
- function computeDigestsForDir(dir, platform) {
34
- const out = {};
35
- for (const base of binaryNames(platform)) {
36
- const full = path.join(dir, base);
37
- out[base] = "sha256:" + crypto.createHash("sha256").update(fs.readFileSync(full)).digest("hex");
38
- }
39
- return out;
40
- }
41
-
42
- function mkPlatformDir(privateKey, options) {
43
- const opts = options || {};
44
- const platform = opts.platform || DEFAULT_PLATFORM;
45
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fallow-lazy-test-"));
46
- for (const base of binaryNames(platform)) {
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 || packageNameForPlatform(platform),
59
- version: opts.version || "2.81.0",
60
- fallowDigests: opts.skipDigests ? undefined : computeDigestsForDir(dir, platform),
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
- // lazy-verify writes diagnostics to fd 2 with fs.writeSync (issue #3276).
73
- const original = fs.writeSync;
74
- fs.writeSync = (fd, chunk, ...rest) => {
75
- if (fd !== 2) return original(fd, chunk, ...rest);
76
- lines.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
77
- return chunk.length;
78
- };
79
- t.after(() => {
80
- fs.writeSync = original;
81
- });
82
- return { lines };
83
- }
84
-
85
- function setupCacheRoot(t) {
86
- const cacheRoot = fs.mkdtempSync(path.join(os.tmpdir(), "fallow-lazy-cache-"));
87
- t.after(() => cleanup(cacheRoot));
88
- return cacheRoot;
89
- }
90
-
91
- function baseInput(dir, verifyFn, extras) {
92
- const manifestPath = path.join(dir, "package.json");
93
- const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
94
- return {
95
- platformPkgDir: dir,
96
- packageName: manifest.name,
97
- manifestPath,
98
- verifyFn,
99
- env: {},
100
- platform: DEFAULT_PLATFORM,
101
- ...extras,
102
- };
103
- }
104
-
105
- // ---- happy path: cache miss then cache hit --------------------------------
106
-
107
- test("ensureVerified verifies on cache miss and writes the sentinel", (t) => {
108
- _resetWarningState();
109
- const { privateKey, rawPub } = makeKeypair();
110
- const dir = mkPlatformDir(privateKey);
111
- t.after(() => cleanup(dir));
112
-
113
- const result = ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
114
- assert.equal(result.ok, true);
115
- assert.equal(result.cached, false);
116
- assert.equal(result.sentinelPath, path.join(dir, SENTINEL_FILENAME));
117
-
118
- // Sentinel file exists and validates
119
- const sentinel = JSON.parse(fs.readFileSync(result.sentinelPath, "utf8"));
120
- assert.equal(sentinel.schemaVersion, SENTINEL_SCHEMA_VERSION);
121
- assert.equal(sentinel.packageVersion, "2.81.0");
122
- assert.equal(sentinel.packageName, "@fallow-cli/test-platform");
123
- assert.equal(Object.keys(sentinel.binaries).length, 2);
124
- });
125
-
126
- test("ensureVerified verifies and caches a win32 executable on any host", (t) => {
127
- _resetWarningState();
128
- const { privateKey, rawPub } = makeKeypair();
129
- const dir = mkPlatformDir(privateKey, { platform: "win32" });
130
- t.after(() => cleanup(dir));
131
- const input = baseInput(dir, (binaryPath) => _verifyWithKey(binaryPath, rawPub), {
132
- platform: "win32",
133
- });
134
-
135
- const verified = ensureVerified(input);
136
- assert.equal(verified.ok, true);
137
- assert.equal(verified.cached, false);
138
- const sentinel = JSON.parse(fs.readFileSync(verified.sentinelPath, "utf8"));
139
- assert.deepEqual(Object.keys(sentinel.binaries), ["fallow.exe", "fallow-similar-code.exe"]);
140
-
141
- const cached = ensureVerified({
142
- ...input,
143
- verifyFn: () => assert.fail("signature verification must not run on a cache hit"),
144
- });
145
- assert.equal(cached.ok, true);
146
- assert.equal(cached.cached, true);
147
- });
148
-
149
- test("sentinel records the same binaries that verify-binary verifies", (t) => {
150
- const cases = [
151
- { platform: "linux", platformId: "linux-x64-gnu" },
152
- { platform: "win32", platformId: "win32-x64-msvc" },
153
- ];
154
- for (const { platform, platformId } of cases) {
155
- _resetWarningState();
156
- const { privateKey, rawPub } = makeKeypair();
157
- const dir = mkPlatformDir(privateKey, { platform });
158
- t.after(() => cleanup(dir));
159
- const input = baseInput(dir, (binaryPath) => _verifyWithKey(binaryPath, rawPub), {
160
- platform,
161
- });
162
-
163
- const result = ensureVerified(input);
164
- assert.equal(result.ok, true);
165
- const sentinel = JSON.parse(fs.readFileSync(result.sentinelPath, "utf8"));
166
- const verified = binaryTargetsForPlatform(platformId).map((target) => target.binary);
167
- assert.deepEqual(Object.keys(sentinel.binaries), verified);
168
- }
169
- });
170
-
171
- test("ensureVerified returns cached:true on a valid sentinel", (t) => {
172
- _resetWarningState();
173
- const { privateKey, rawPub } = makeKeypair();
174
- const dir = mkPlatformDir(privateKey);
175
- t.after(() => cleanup(dir));
176
-
177
- // First call writes sentinel
178
- ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
179
-
180
- // Second call should hit cache (verifyFn must NOT be called)
181
- let verifyCallCount = 0;
182
- const result = ensureVerified(
183
- baseInput(dir, (p) => {
184
- verifyCallCount += 1;
185
- return _verifyWithKey(p, rawPub);
186
- }),
187
- );
188
- assert.equal(result.ok, true);
189
- assert.equal(result.cached, true);
190
- assert.equal(verifyCallCount, 0, "sig verify should NOT have run on a cache hit");
191
- });
192
-
193
- // ---- cache invalidation modes ---------------------------------------------
194
-
195
- test("ensureVerified invalidates sentinel on mtime drift", (t) => {
196
- _resetWarningState();
197
- const { privateKey, rawPub } = makeKeypair();
198
- const dir = mkPlatformDir(privateKey);
199
- t.after(() => cleanup(dir));
200
-
201
- ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
202
-
203
- // Bump the mtime of one binary; sentinel should now be stale.
204
- const newTime = new Date(Date.now() + 10_000);
205
- fs.utimesSync(path.join(dir, binaryNames(DEFAULT_PLATFORM)[0]), newTime, newTime);
206
-
207
- let verifyCallCount = 0;
208
- const result = ensureVerified(
209
- baseInput(dir, (p) => {
210
- verifyCallCount += 1;
211
- return _verifyWithKey(p, rawPub);
212
- }),
213
- );
214
- assert.equal(result.ok, true);
215
- assert.equal(result.cached, false);
216
- assert.equal(
217
- verifyCallCount,
218
- binaryNames(DEFAULT_PLATFORM).length,
219
- "verify should rerun for every shipped binary",
220
- );
221
- });
222
-
223
- test("ensureVerified invalidates sentinel on packageVersion drift", (t) => {
224
- _resetWarningState();
225
- const { privateKey, rawPub } = makeKeypair();
226
- const dir = mkPlatformDir(privateKey);
227
- t.after(() => cleanup(dir));
228
-
229
- ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
230
-
231
- // Rewrite manifest with a different version.
232
- const manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
233
- manifest.version = "2.81.1";
234
- fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(manifest));
235
-
236
- const result = ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
237
- assert.equal(result.cached, false);
238
- });
239
-
240
- test("ensureVerified invalidates sentinel on packageName drift", (t) => {
241
- _resetWarningState();
242
- const { privateKey, rawPub } = makeKeypair();
243
- const dir = mkPlatformDir(privateKey, { packageName: "@fallow-cli/x" });
244
- t.after(() => cleanup(dir));
245
-
246
- ensureVerified({
247
- ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
248
- packageName: "@fallow-cli/x",
249
- });
250
-
251
- // Now claim a different package name; sentinel becomes stale.
252
- const result = ensureVerified({
253
- ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
254
- packageName: "@fallow-cli/y",
255
- });
256
- // Manifest still says @fallow-cli/x, so sentinel validates against manifest
257
- // but the sentinel was originally written for x. Since we pass packageName=y
258
- // for the cache lookup but the manifest still says x, the sentinel
259
- // .packageName=x matches the manifest.name=x. We must rewrite manifest too
260
- // to truly drift. Skip this case and instead force sentinel rewrite to
261
- // have a different name:
262
- fs.writeFileSync(
263
- result.sentinelPath || path.join(dir, SENTINEL_FILENAME),
264
- JSON.stringify({
265
- schemaVersion: SENTINEL_SCHEMA_VERSION,
266
- verifiedAt: new Date().toISOString(),
267
- packageVersion: "2.81.0",
268
- packageName: "@fallow-cli/wrong-name",
269
- binaries: {
270
- [binaryNames(DEFAULT_PLATFORM)[0]]: {
271
- mtimeMs: fs.statSync(path.join(dir, binaryNames(DEFAULT_PLATFORM)[0])).mtimeMs,
272
- },
273
- },
274
- }),
275
- );
276
-
277
- const result2 = ensureVerified({
278
- ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
279
- packageName: "@fallow-cli/x",
280
- });
281
- assert.equal(result2.cached, false);
282
- });
283
-
284
- test("ensureVerified invalidates sentinel on malformed JSON", (t) => {
285
- _resetWarningState();
286
- const { privateKey, rawPub } = makeKeypair();
287
- const dir = mkPlatformDir(privateKey);
288
- t.after(() => cleanup(dir));
289
-
290
- ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
291
- fs.writeFileSync(path.join(dir, SENTINEL_FILENAME), "not-json-at-all");
292
-
293
- const result = ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
294
- assert.equal(result.cached, false);
295
- });
296
-
297
- test("ensureVerified invalidates sentinel on schemaVersion drift", (t) => {
298
- _resetWarningState();
299
- const { privateKey, rawPub } = makeKeypair();
300
- const dir = mkPlatformDir(privateKey);
301
- t.after(() => cleanup(dir));
302
-
303
- ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
304
- const sentinelPath = path.join(dir, SENTINEL_FILENAME);
305
- const data = JSON.parse(fs.readFileSync(sentinelPath, "utf8"));
306
- data.schemaVersion = 999;
307
- fs.writeFileSync(sentinelPath, JSON.stringify(data));
308
-
309
- const result = ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
310
- assert.equal(result.cached, false);
311
- });
312
-
313
- // ---- failure modes --------------------------------------------------------
314
-
315
- test("ensureVerified returns sig-invalid on a tampered signature", (t) => {
316
- _resetWarningState();
317
- const { privateKey, rawPub } = makeKeypair();
318
- const dir = mkPlatformDir(privateKey, { corruptSigFor: binaryNames(DEFAULT_PLATFORM)[0] });
319
- t.after(() => cleanup(dir));
320
-
321
- const result = ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
322
- assert.equal(result.ok, false);
323
- assert.equal(result.code, "sig-invalid");
324
- assert.match(result.binary, /fallow/);
325
- // Sentinel must NOT have been written on failure
326
- assert.equal(fs.existsSync(path.join(dir, SENTINEL_FILENAME)), false);
327
- });
328
-
329
- test("ensureVerified returns digest-unavailable on a pre-#597 manifest", (t) => {
330
- _resetWarningState();
331
- const { privateKey, rawPub } = makeKeypair();
332
- const dir = mkPlatformDir(privateKey, { skipDigests: true });
333
- t.after(() => cleanup(dir));
334
-
335
- const result = ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
336
- assert.equal(result.ok, false);
337
- assert.equal(result.code, "digest-unavailable");
338
- assert.match(result.message, /predates fallow 2\.78\.1/);
339
- assert.match(result.message, new RegExp(SKIP_ENV));
340
- });
341
-
342
- // ---- cache-dir cascade ----------------------------------------------------
343
-
344
- test("ensureVerified honors FALLOW_VERIFY_CACHE_DIR when platform pkg dir is non-writable", (t) => {
345
- _resetWarningState();
346
- const { privateKey, rawPub } = makeKeypair();
347
- const dir = mkPlatformDir(privateKey);
348
- const cacheRoot = setupCacheRoot(t);
349
- t.after(() => cleanup(dir));
350
-
351
- const result = ensureVerified({
352
- ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
353
- env: { FALLOW_VERIFY_CACHE_DIR: cacheRoot },
354
- isWritable: (candidate) => candidate !== dir,
355
- });
356
- assert.equal(result.ok, true);
357
- assert.equal(result.cached, false);
358
- assert.match(result.sentinelPath, new RegExp(cacheRoot.replace(/\\/g, "\\\\")));
359
- });
360
-
361
- test("ensureVerified emits a single warning when no sentinel location is writable", (t) => {
362
- _resetWarningState();
363
- const { privateKey, rawPub } = makeKeypair();
364
- const dir = mkPlatformDir(privateKey);
365
- const stderr = captureStderr(t);
366
- t.after(() => cleanup(dir));
367
- const input = {
368
- ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
369
- homedir: undefined,
370
- isWritable: () => false,
371
- };
372
-
373
- const result = ensureVerified(input);
374
- assert.equal(result.sentinelPath, null);
375
- const warnings = stderr.lines.filter((line) => line.includes("no writable cache location"));
376
- assert.equal(warnings.length, 1);
377
-
378
- ensureVerified(input);
379
- const repeatedWarnings = stderr.lines.filter((line) =>
380
- line.includes("no writable cache location"),
381
- );
382
- assert.equal(repeatedWarnings.length, 1);
383
- });
384
-
385
- // ---- FALLOW_SKIP_BINARY_VERIFY -------------------------------------------
386
-
387
- test("ensureVerified short-circuits when FALLOW_SKIP_BINARY_VERIFY is set", () => {
388
- _resetWarningState();
389
- const result = ensureVerified({
390
- platformPkgDir: "/this/path/does/not/exist",
391
- packageName: "@fallow-cli/x",
392
- manifestPath: "/also/missing",
393
- env: { [SKIP_ENV]: "1" },
394
- });
395
- assert.equal(result.ok, true);
396
- assert.equal(result.skipped, true);
397
- assert.match(result.reason, new RegExp(SKIP_ENV));
398
- });
399
-
400
- // ---- FALLOW_VERIFY_LOG ----------------------------------------------------
401
-
402
- test("ensureVerified emits one stderr line per outcome when FALLOW_VERIFY_LOG=1", (t) => {
403
- _resetWarningState();
404
- const { privateKey, rawPub } = makeKeypair();
405
- const dir = mkPlatformDir(privateKey);
406
- t.after(() => cleanup(dir));
407
-
408
- const stderr = captureStderr(t);
409
- // First invocation: cache miss
410
- ensureVerified({
411
- ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
412
- env: { FALLOW_VERIFY_LOG: "1" },
413
- });
414
- // Second invocation: cache hit
415
- ensureVerified({
416
- ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
417
- env: { FALLOW_VERIFY_LOG: "1" },
418
- });
419
-
420
- const logs = stderr.lines.filter((l) => l.startsWith("fallow-verify "));
421
- assert.equal(logs.length, 2);
422
- assert.match(logs[0], /outcome=ok cache=miss/);
423
- assert.match(logs[1], /outcome=ok cache=hit/);
424
- });
425
-
426
- test("ensureVerified does not log when FALLOW_VERIFY_LOG is unset", (t) => {
427
- _resetWarningState();
428
- const { privateKey, rawPub } = makeKeypair();
429
- const dir = mkPlatformDir(privateKey);
430
- t.after(() => cleanup(dir));
431
-
432
- const stderr = captureStderr(t);
433
- ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
434
- const logs = stderr.lines.filter((l) => l.startsWith("fallow-verify "));
435
- assert.equal(logs.length, 0);
436
- });
437
-
438
- // ---- concurrency ----------------------------------------------------------
439
-
440
- test("ensureVerified is idempotent under concurrent first-runs", async (t) => {
441
- _resetWarningState();
442
- const { privateKey, rawPub } = makeKeypair();
443
- const dir = mkPlatformDir(privateKey);
444
- t.after(() => cleanup(dir));
445
-
446
- const calls = await Promise.all([
447
- Promise.resolve().then(() => ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)))),
448
- Promise.resolve().then(() => ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)))),
449
- Promise.resolve().then(() => ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)))),
450
- ]);
451
-
452
- for (const r of calls) assert.equal(r.ok, true);
453
- // Sentinel exists and is valid JSON
454
- const sentinel = JSON.parse(fs.readFileSync(path.join(dir, SENTINEL_FILENAME), "utf8"));
455
- assert.equal(sentinel.schemaVersion, SENTINEL_SCHEMA_VERSION);
456
- assert.equal(sentinel.packageName, "@fallow-cli/test-platform");
457
-
458
- // No leftover .tmp files in the dir
459
- const files = fs.readdirSync(dir);
460
- const tmps = files.filter((f) => f.includes(".tmp"));
461
- assert.deepEqual(tmps, [], "no leftover temp files from concurrent writes");
462
- });
463
-
464
- // ---- cross-install sentinel reuse (security regression test) ----------
465
-
466
- test("ensureVerified rejects a sentinel written for a different install dir", (t) => {
467
- _resetWarningState();
468
- // Two installs of the same package + version. install A is clean and writes
469
- // a sentinel to a shared cache; install B has a tampered binary at the same
470
- // package name + version. B must NOT trust A's sentinel via cache hit even
471
- // when the recorded mtimes happen to match B's binary mtimes.
472
- const { privateKey, rawPub } = makeKeypair();
473
- const installA = mkPlatformDir(privateKey);
474
- const installB = mkPlatformDir(privateKey);
475
- // Tamper install B's fallow binary AFTER mkPlatformDir wrote a valid sig
476
- // for the original bytes (mkPlatformDir does not expose a corrupt-binary
477
- // option, so simulate the attack by overwriting bytes here).
478
- fs.writeFileSync(
479
- path.join(installB, binaryNames(DEFAULT_PLATFORM)[0]),
480
- Buffer.from("tampered bytes"),
481
- );
482
- const sharedCache = fs.mkdtempSync(path.join(os.tmpdir(), "fallow-shared-cache-"));
483
- const isWritable = (candidate) => candidate !== installA && candidate !== installB;
484
-
485
- t.after(() => {
486
- cleanup(installA);
487
- cleanup(installB);
488
- cleanup(sharedCache);
489
- });
490
-
491
- // Install A: clean verify. Sentinel lands in the shared cache because the
492
- // platform pkg dir is read-only.
493
- const resultA = ensureVerified({
494
- ...baseInput(installA, (p) => _verifyWithKey(p, rawPub)),
495
- env: { FALLOW_VERIFY_CACHE_DIR: sharedCache },
496
- isWritable,
497
- });
498
- assert.equal(resultA.ok, true);
499
- assert.match(resultA.sentinelPath, new RegExp(sharedCache.replace(/\\/g, "\\\\")));
500
-
501
- // Attacker on install B copies install A's mtimes onto B's binaries so the
502
- // mtime pre-filter would have matched. With only mtime + name + version
503
- // gates this would produce a cache hit and skip verify; the platformPkgDir
504
- // + SHA-256 binding must prevent that.
505
- for (const name of binaryNames()) {
506
- const aStat = fs.statSync(path.join(installA, name));
507
- fs.utimesSync(path.join(installB, name), aStat.atime, aStat.mtime);
508
- }
509
-
510
- let verifyCallCount = 0;
511
- const resultB = ensureVerified({
512
- ...baseInput(installB, (p) => {
513
- verifyCallCount += 1;
514
- return _verifyWithKey(p, rawPub);
515
- }),
516
- env: { FALLOW_VERIFY_CACHE_DIR: sharedCache },
517
- isWritable,
518
- });
519
- assert.equal(resultB.ok, false);
520
- assert.equal(resultB.code, "sig-invalid");
521
- assert.ok(verifyCallCount > 0, "expected re-verify on cross-install sentinel read");
522
- });
523
-
524
- test("ensureVerified rejects a sentinel where bytes drift but mtime stays", (t) => {
525
- _resetWarningState();
526
- const { privateKey, rawPub } = makeKeypair();
527
- const dir = mkPlatformDir(privateKey);
528
- t.after(() => cleanup(dir));
529
-
530
- // First invocation writes a sentinel with the clean SHA-256.
531
- ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
532
-
533
- // Tamper the binary in place AND restore the prior mtime, so the mtime
534
- // pre-filter matches but the bytes do not.
535
- const binPath = path.join(dir, binaryNames(DEFAULT_PLATFORM)[0]);
536
- const before = fs.statSync(binPath);
537
- fs.writeFileSync(binPath, Buffer.from("tampered"));
538
- fs.utimesSync(binPath, before.atime, before.mtime);
539
-
540
- let verifyCallCount = 0;
541
- const result = ensureVerified(
542
- baseInput(dir, (p) => {
543
- verifyCallCount += 1;
544
- return _verifyWithKey(p, rawPub);
545
- }),
546
- );
547
- assert.equal(result.ok, false);
548
- assert.equal(result.code, "sig-invalid");
549
- assert.ok(verifyCallCount > 0, "expected re-verify when bytes diverge from sentinel SHA");
550
- });
551
-
552
- // ---- FALLOW_SKIP_BINARY_VERIFY warning (regression test for documented contract) ----
553
-
554
- test("ensureVerified warns once on stderr when FALLOW_SKIP_BINARY_VERIFY is set", (t) => {
555
- _resetWarningState();
556
- const stderr = captureStderr(t);
557
- const env = { [SKIP_ENV]: "1" };
558
- ensureVerified({ platformPkgDir: "/x", packageName: "@fallow-cli/y", manifestPath: "/z", env });
559
- ensureVerified({ platformPkgDir: "/x", packageName: "@fallow-cli/y", manifestPath: "/z", env });
560
- const warnings = stderr.lines.filter(
561
- (l) => l.includes(`${SKIP_ENV} is set`) && l.includes("verification is skipped"),
562
- );
563
- assert.equal(warnings.length, 1, "warning should fire exactly once per process");
564
- });