fallow 3.17.0 → 3.19.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/README.md +2 -2
- package/capabilities.json +166 -17
- package/issue-registry.json +2 -2
- package/package.json +11 -10
- package/schema.json +43 -4
- package/scripts/lazy-verify.js +20 -6
- package/scripts/lazy-verify.test.js +86 -103
- package/scripts/sentinel-path.test.js +9 -2
- package/scripts/verify-binary.js +10 -9
- package/scripts/verify-binary.test.js +56 -10
- package/skills/fallow/SKILL.md +21 -21
- package/skills/fallow/references/cli-reference.md +74 -6
- package/skills/fallow/references/gotchas.md +2 -2
- package/skills/fallow/references/mcp.md +47 -5
- package/skills/fallow/references/node-bindings.md +3 -2
- package/skills/fallow/references/patterns.md +2 -2
- package/skills/fallow/references/similar-code.md +47 -0
- package/types/output-contract.d.ts +786 -76
|
@@ -16,6 +16,9 @@ const { _verifyWithKey, SKIP_ENV } = require("./verify-binary");
|
|
|
16
16
|
|
|
17
17
|
// ---- shared fixtures ------------------------------------------------------
|
|
18
18
|
|
|
19
|
+
const DEFAULT_PLATFORM = "linux";
|
|
20
|
+
const DEFAULT_PACKAGE_NAME = "@fallow-cli/test-platform";
|
|
21
|
+
|
|
19
22
|
function makeKeypair() {
|
|
20
23
|
const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
|
|
21
24
|
const spki = publicKey.export({ format: "der", type: "spki" });
|
|
@@ -23,18 +26,18 @@ function makeKeypair() {
|
|
|
23
26
|
return { privateKey, rawPub };
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
function
|
|
27
|
-
return
|
|
29
|
+
function packageNameForPlatform(platform) {
|
|
30
|
+
return platform === "win32" ? "@fallow-cli/win32-x64-msvc" : DEFAULT_PACKAGE_NAME;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
function binaryNames() {
|
|
31
|
-
|
|
32
|
-
return [`fallow${ext
|
|
33
|
+
function binaryNames(platform) {
|
|
34
|
+
const ext = platform === "win32" ? ".exe" : "";
|
|
35
|
+
return [`fallow${ext}`, `fallow-similar-code${ext}`];
|
|
33
36
|
}
|
|
34
37
|
|
|
35
|
-
function computeDigestsForDir(dir) {
|
|
38
|
+
function computeDigestsForDir(dir, platform) {
|
|
36
39
|
const out = {};
|
|
37
|
-
for (const base of binaryNames()) {
|
|
40
|
+
for (const base of binaryNames(platform)) {
|
|
38
41
|
const full = path.join(dir, base);
|
|
39
42
|
out[base] = "sha256:" + crypto.createHash("sha256").update(fs.readFileSync(full)).digest("hex");
|
|
40
43
|
}
|
|
@@ -43,8 +46,9 @@ function computeDigestsForDir(dir) {
|
|
|
43
46
|
|
|
44
47
|
function mkPlatformDir(privateKey, options) {
|
|
45
48
|
const opts = options || {};
|
|
49
|
+
const platform = opts.platform || DEFAULT_PLATFORM;
|
|
46
50
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fallow-lazy-test-"));
|
|
47
|
-
for (const base of binaryNames()) {
|
|
51
|
+
for (const base of binaryNames(platform)) {
|
|
48
52
|
const binaryPath = path.join(dir, base);
|
|
49
53
|
const content = Buffer.from(`mock ${base}`);
|
|
50
54
|
fs.writeFileSync(binaryPath, content);
|
|
@@ -56,9 +60,9 @@ function mkPlatformDir(privateKey, options) {
|
|
|
56
60
|
fs.writeFileSync(
|
|
57
61
|
path.join(dir, "package.json"),
|
|
58
62
|
JSON.stringify({
|
|
59
|
-
name: opts.packageName ||
|
|
63
|
+
name: opts.packageName || packageNameForPlatform(platform),
|
|
60
64
|
version: opts.version || "2.81.0",
|
|
61
|
-
fallowDigests: opts.skipDigests ? undefined : computeDigestsForDir(dir),
|
|
65
|
+
fallowDigests: opts.skipDigests ? undefined : computeDigestsForDir(dir, platform),
|
|
62
66
|
}),
|
|
63
67
|
);
|
|
64
68
|
return dir;
|
|
@@ -88,13 +92,15 @@ function setupCacheRoot(t) {
|
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
function baseInput(dir, verifyFn, extras) {
|
|
95
|
+
const manifestPath = path.join(dir, "package.json");
|
|
96
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
91
97
|
return {
|
|
92
98
|
platformPkgDir: dir,
|
|
93
|
-
packageName:
|
|
94
|
-
manifestPath
|
|
99
|
+
packageName: manifest.name,
|
|
100
|
+
manifestPath,
|
|
95
101
|
verifyFn,
|
|
96
102
|
env: {},
|
|
97
|
-
platform:
|
|
103
|
+
platform: DEFAULT_PLATFORM,
|
|
98
104
|
...extras,
|
|
99
105
|
};
|
|
100
106
|
}
|
|
@@ -117,7 +123,30 @@ test("ensureVerified verifies on cache miss and writes the sentinel", (t) => {
|
|
|
117
123
|
assert.equal(sentinel.schemaVersion, SENTINEL_SCHEMA_VERSION);
|
|
118
124
|
assert.equal(sentinel.packageVersion, "2.81.0");
|
|
119
125
|
assert.equal(sentinel.packageName, "@fallow-cli/test-platform");
|
|
120
|
-
assert.equal(Object.keys(sentinel.binaries).length,
|
|
126
|
+
assert.equal(Object.keys(sentinel.binaries).length, 2);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("ensureVerified verifies and caches a win32 executable on any host", (t) => {
|
|
130
|
+
_resetWarningState();
|
|
131
|
+
const { privateKey, rawPub } = makeKeypair();
|
|
132
|
+
const dir = mkPlatformDir(privateKey, { platform: "win32" });
|
|
133
|
+
t.after(() => cleanup(dir));
|
|
134
|
+
const input = baseInput(dir, (binaryPath) => _verifyWithKey(binaryPath, rawPub), {
|
|
135
|
+
platform: "win32",
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const verified = ensureVerified(input);
|
|
139
|
+
assert.equal(verified.ok, true);
|
|
140
|
+
assert.equal(verified.cached, false);
|
|
141
|
+
const sentinel = JSON.parse(fs.readFileSync(verified.sentinelPath, "utf8"));
|
|
142
|
+
assert.deepEqual(Object.keys(sentinel.binaries), ["fallow.exe", "fallow-similar-code.exe"]);
|
|
143
|
+
|
|
144
|
+
const cached = ensureVerified({
|
|
145
|
+
...input,
|
|
146
|
+
verifyFn: () => assert.fail("signature verification must not run on a cache hit"),
|
|
147
|
+
});
|
|
148
|
+
assert.equal(cached.ok, true);
|
|
149
|
+
assert.equal(cached.cached, true);
|
|
121
150
|
});
|
|
122
151
|
|
|
123
152
|
test("ensureVerified returns cached:true on a valid sentinel", (t) => {
|
|
@@ -154,7 +183,7 @@ test("ensureVerified invalidates sentinel on mtime drift", (t) => {
|
|
|
154
183
|
|
|
155
184
|
// Bump the mtime of one binary; sentinel should now be stale.
|
|
156
185
|
const newTime = new Date(Date.now() + 10_000);
|
|
157
|
-
fs.utimesSync(path.join(dir,
|
|
186
|
+
fs.utimesSync(path.join(dir, binaryNames(DEFAULT_PLATFORM)[0]), newTime, newTime);
|
|
158
187
|
|
|
159
188
|
let verifyCallCount = 0;
|
|
160
189
|
const result = ensureVerified(
|
|
@@ -165,7 +194,11 @@ test("ensureVerified invalidates sentinel on mtime drift", (t) => {
|
|
|
165
194
|
);
|
|
166
195
|
assert.equal(result.ok, true);
|
|
167
196
|
assert.equal(result.cached, false);
|
|
168
|
-
assert.equal(
|
|
197
|
+
assert.equal(
|
|
198
|
+
verifyCallCount,
|
|
199
|
+
binaryNames(DEFAULT_PLATFORM).length,
|
|
200
|
+
"verify should rerun for every shipped binary",
|
|
201
|
+
);
|
|
169
202
|
});
|
|
170
203
|
|
|
171
204
|
test("ensureVerified invalidates sentinel on packageVersion drift", (t) => {
|
|
@@ -215,7 +248,9 @@ test("ensureVerified invalidates sentinel on packageName drift", (t) => {
|
|
|
215
248
|
packageVersion: "2.81.0",
|
|
216
249
|
packageName: "@fallow-cli/wrong-name",
|
|
217
250
|
binaries: {
|
|
218
|
-
[
|
|
251
|
+
[binaryNames(DEFAULT_PLATFORM)[0]]: {
|
|
252
|
+
mtimeMs: fs.statSync(path.join(dir, binaryNames(DEFAULT_PLATFORM)[0])).mtimeMs,
|
|
253
|
+
},
|
|
219
254
|
},
|
|
220
255
|
}),
|
|
221
256
|
);
|
|
@@ -261,7 +296,7 @@ test("ensureVerified invalidates sentinel on schemaVersion drift", (t) => {
|
|
|
261
296
|
test("ensureVerified returns sig-invalid on a tampered signature", (t) => {
|
|
262
297
|
_resetWarningState();
|
|
263
298
|
const { privateKey, rawPub } = makeKeypair();
|
|
264
|
-
const dir = mkPlatformDir(privateKey, { corruptSigFor:
|
|
299
|
+
const dir = mkPlatformDir(privateKey, { corruptSigFor: binaryNames(DEFAULT_PLATFORM)[0] });
|
|
265
300
|
t.after(() => cleanup(dir));
|
|
266
301
|
|
|
267
302
|
const result = ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
|
|
@@ -289,89 +324,43 @@ test("ensureVerified returns digest-unavailable on a pre-#597 manifest", (t) =>
|
|
|
289
324
|
|
|
290
325
|
test("ensureVerified honors FALLOW_VERIFY_CACHE_DIR when platform pkg dir is non-writable", (t) => {
|
|
291
326
|
_resetWarningState();
|
|
292
|
-
if (process.platform === "win32") {
|
|
293
|
-
t.skip("Windows ACL chmod is not portable; covered by sentinel-path tests");
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
327
|
const { privateKey, rawPub } = makeKeypair();
|
|
297
328
|
const dir = mkPlatformDir(privateKey);
|
|
298
329
|
const cacheRoot = setupCacheRoot(t);
|
|
330
|
+
t.after(() => cleanup(dir));
|
|
299
331
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
assert.match(result.sentinelPath, new RegExp(cacheRoot.replace(/\\/g, "\\\\")));
|
|
309
|
-
} finally {
|
|
310
|
-
fs.chmodSync(dir, 0o755);
|
|
311
|
-
cleanup(dir);
|
|
312
|
-
}
|
|
332
|
+
const result = ensureVerified({
|
|
333
|
+
...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
|
|
334
|
+
env: { FALLOW_VERIFY_CACHE_DIR: cacheRoot },
|
|
335
|
+
isWritable: (candidate) => candidate !== dir,
|
|
336
|
+
});
|
|
337
|
+
assert.equal(result.ok, true);
|
|
338
|
+
assert.equal(result.cached, false);
|
|
339
|
+
assert.match(result.sentinelPath, new RegExp(cacheRoot.replace(/\\/g, "\\\\")));
|
|
313
340
|
});
|
|
314
341
|
|
|
315
|
-
test("ensureVerified emits a single warning when sentinel
|
|
342
|
+
test("ensureVerified emits a single warning when no sentinel location is writable", (t) => {
|
|
316
343
|
_resetWarningState();
|
|
317
|
-
if (process.platform === "win32") {
|
|
318
|
-
t.skip("Windows ACL chmod is not portable");
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
344
|
const { privateKey, rawPub } = makeKeypair();
|
|
322
345
|
const dir = mkPlatformDir(privateKey);
|
|
323
346
|
const stderr = captureStderr(t);
|
|
347
|
+
t.after(() => cleanup(dir));
|
|
348
|
+
const input = {
|
|
349
|
+
...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
|
|
350
|
+
homedir: undefined,
|
|
351
|
+
isWritable: () => false,
|
|
352
|
+
};
|
|
324
353
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
// runs writeSentinel. Simpler: instead of fighting the cascade in env,
|
|
330
|
-
// simulate write failure via a non-writable FALLOW_VERIFY_CACHE_DIR that
|
|
331
|
-
// PASSES isWritable() but then has its perms revoked between resolve and
|
|
332
|
-
// write. Since that race is hard to script, we test the warn-once helper
|
|
333
|
-
// via a synthetic path that fails the rename atomically: pass a sentinel-
|
|
334
|
-
// chmod-locked dir.
|
|
335
|
-
//
|
|
336
|
-
// The portable shape: chmod the platform pkg dir read-only AND pass a
|
|
337
|
-
// FALLOW_VERIFY_CACHE_DIR that is a regular file (not a dir). isWritable
|
|
338
|
-
// returns false for both, so resolveSentinelPath falls through to XDG. If
|
|
339
|
-
// the user has no HOME (synthetic env), the cascade returns null. We can
|
|
340
|
-
// achieve this only by injecting both env.HOME='' AND env.XDG_CACHE_HOME=''
|
|
341
|
-
// AND ensuring os.homedir() is not consulted; ensureVerified does not
|
|
342
|
-
// accept a homedir override, so the warn-once path is unreachable via env
|
|
343
|
-
// alone on a machine with a real HOME. Skip this test on machines with a
|
|
344
|
-
// real homedir; the warn-once helper is otherwise covered by the
|
|
345
|
-
// "no-writable-location" branch in sentinel-path tests.
|
|
346
|
-
if (os.homedir() && os.homedir().length > 0) {
|
|
347
|
-
t.skip(
|
|
348
|
-
"warn-once-on-no-writable-cache requires homedir-override knob (covered by sentinel-path unit test)",
|
|
349
|
-
);
|
|
350
|
-
cleanup(dir);
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
354
|
+
const result = ensureVerified(input);
|
|
355
|
+
assert.equal(result.sentinelPath, null);
|
|
356
|
+
const warnings = stderr.lines.filter((line) => line.includes("no writable cache location"));
|
|
357
|
+
assert.equal(warnings.length, 1);
|
|
353
358
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
env,
|
|
360
|
-
});
|
|
361
|
-
if (result.sentinelPath !== null) {
|
|
362
|
-
t.diagnostic(`sentinel landed at ${result.sentinelPath} despite empty HOME; skipping`);
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
365
|
-
const warnings = stderr.lines.filter((l) => l.includes("no writable cache location"));
|
|
366
|
-
assert.equal(warnings.length, 1);
|
|
367
|
-
// Second call: no new warning.
|
|
368
|
-
ensureVerified({ ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)), env });
|
|
369
|
-
const warnings2 = stderr.lines.filter((l) => l.includes("no writable cache location"));
|
|
370
|
-
assert.equal(warnings2.length, 1);
|
|
371
|
-
} finally {
|
|
372
|
-
fs.chmodSync(dir, 0o755);
|
|
373
|
-
cleanup(dir);
|
|
374
|
-
}
|
|
359
|
+
ensureVerified(input);
|
|
360
|
+
const repeatedWarnings = stderr.lines.filter((line) =>
|
|
361
|
+
line.includes("no writable cache location"),
|
|
362
|
+
);
|
|
363
|
+
assert.equal(repeatedWarnings.length, 1);
|
|
375
364
|
});
|
|
376
365
|
|
|
377
366
|
// ---- FALLOW_SKIP_BINARY_VERIFY -------------------------------------------
|
|
@@ -457,10 +446,6 @@ test("ensureVerified is idempotent under concurrent first-runs", async (t) => {
|
|
|
457
446
|
|
|
458
447
|
test("ensureVerified rejects a sentinel written for a different install dir", (t) => {
|
|
459
448
|
_resetWarningState();
|
|
460
|
-
if (process.platform === "win32") {
|
|
461
|
-
t.skip("chmod-based read-only platform pkg dir is not portable on Windows");
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
449
|
// Two installs of the same package + version. install A is clean and writes
|
|
465
450
|
// a sentinel to a shared cache; install B has a tampered binary at the same
|
|
466
451
|
// package name + version. B must NOT trust A's sentinel via cache hit even
|
|
@@ -471,17 +456,14 @@ test("ensureVerified rejects a sentinel written for a different install dir", (t
|
|
|
471
456
|
// Tamper install B's fallow binary AFTER mkPlatformDir wrote a valid sig
|
|
472
457
|
// for the original bytes (mkPlatformDir does not expose a corrupt-binary
|
|
473
458
|
// option, so simulate the attack by overwriting bytes here).
|
|
474
|
-
fs.writeFileSync(
|
|
459
|
+
fs.writeFileSync(
|
|
460
|
+
path.join(installB, binaryNames(DEFAULT_PLATFORM)[0]),
|
|
461
|
+
Buffer.from("tampered bytes"),
|
|
462
|
+
);
|
|
475
463
|
const sharedCache = fs.mkdtempSync(path.join(os.tmpdir(), "fallow-shared-cache-"));
|
|
464
|
+
const isWritable = (candidate) => candidate !== installA && candidate !== installB;
|
|
476
465
|
|
|
477
|
-
// Force the shared-cache cascade by making both platform pkg dirs
|
|
478
|
-
// non-writable (simulates yarn PnP / Docker layered images / pnpm
|
|
479
|
-
// verify-store invariants).
|
|
480
|
-
fs.chmodSync(installA, 0o555);
|
|
481
|
-
fs.chmodSync(installB, 0o555);
|
|
482
466
|
t.after(() => {
|
|
483
|
-
fs.chmodSync(installA, 0o755);
|
|
484
|
-
fs.chmodSync(installB, 0o755);
|
|
485
467
|
cleanup(installA);
|
|
486
468
|
cleanup(installB);
|
|
487
469
|
cleanup(sharedCache);
|
|
@@ -492,6 +474,7 @@ test("ensureVerified rejects a sentinel written for a different install dir", (t
|
|
|
492
474
|
const resultA = ensureVerified({
|
|
493
475
|
...baseInput(installA, (p) => _verifyWithKey(p, rawPub)),
|
|
494
476
|
env: { FALLOW_VERIFY_CACHE_DIR: sharedCache },
|
|
477
|
+
isWritable,
|
|
495
478
|
});
|
|
496
479
|
assert.equal(resultA.ok, true);
|
|
497
480
|
assert.match(resultA.sentinelPath, new RegExp(sharedCache.replace(/\\/g, "\\\\")));
|
|
@@ -502,7 +485,6 @@ test("ensureVerified rejects a sentinel written for a different install dir", (t
|
|
|
502
485
|
// + SHA-256 binding must prevent that.
|
|
503
486
|
for (const name of binaryNames()) {
|
|
504
487
|
const aStat = fs.statSync(path.join(installA, name));
|
|
505
|
-
fs.chmodSync(path.join(installB, name), 0o644);
|
|
506
488
|
fs.utimesSync(path.join(installB, name), aStat.atime, aStat.mtime);
|
|
507
489
|
}
|
|
508
490
|
|
|
@@ -513,6 +495,7 @@ test("ensureVerified rejects a sentinel written for a different install dir", (t
|
|
|
513
495
|
return _verifyWithKey(p, rawPub);
|
|
514
496
|
}),
|
|
515
497
|
env: { FALLOW_VERIFY_CACHE_DIR: sharedCache },
|
|
498
|
+
isWritable,
|
|
516
499
|
});
|
|
517
500
|
assert.equal(resultB.ok, false);
|
|
518
501
|
assert.equal(resultB.code, "sig-invalid");
|
|
@@ -530,7 +513,7 @@ test("ensureVerified rejects a sentinel where bytes drift but mtime stays", (t)
|
|
|
530
513
|
|
|
531
514
|
// Tamper the binary in place AND restore the prior mtime, so the mtime
|
|
532
515
|
// pre-filter matches but the bytes do not.
|
|
533
|
-
const binPath = path.join(dir,
|
|
516
|
+
const binPath = path.join(dir, binaryNames(DEFAULT_PLATFORM)[0]);
|
|
534
517
|
const before = fs.statSync(binPath);
|
|
535
518
|
fs.writeFileSync(binPath, Buffer.from("tampered"));
|
|
536
519
|
fs.utimesSync(binPath, before.atime, before.mtime);
|
|
@@ -77,7 +77,7 @@ test("resolveSentinelPath falls back to FALLOW_VERIFY_CACHE_DIR when platform pk
|
|
|
77
77
|
const cacheDir = mkTmp();
|
|
78
78
|
try {
|
|
79
79
|
const result = resolveSentinelPath({
|
|
80
|
-
platformPkgDir: "
|
|
80
|
+
platformPkgDir: path.join(cacheDir, "missing-platform-package"),
|
|
81
81
|
packageName: "@fallow-cli/darwin-arm64",
|
|
82
82
|
env: { FALLOW_VERIFY_CACHE_DIR: cacheDir },
|
|
83
83
|
});
|
|
@@ -220,12 +220,19 @@ test("resolveSentinelPath honors injected isWritable + ensureDir for full test i
|
|
|
220
220
|
test("resolveSentinelPath skips cache-dir-env when ensureDir fails for it", () => {
|
|
221
221
|
// FALLOW_VERIFY_CACHE_DIR points at a non-creatable path, XDG points at a
|
|
222
222
|
// creatable one. Confirm the resolver moves past the env override.
|
|
223
|
+
//
|
|
224
|
+
// A directory nested under a regular FILE is the portable way to make mkdir
|
|
225
|
+
// fail: `/dev/null/inside/a/file` only refuses on POSIX, and on Windows the
|
|
226
|
+
// recursive mkdir succeeds against the current drive, which both defeats the
|
|
227
|
+
// assertion and creates C:\dev outside any temp directory.
|
|
223
228
|
const homeDir = mkTmp();
|
|
229
|
+
const blocker = path.join(homeDir, "not-a-directory");
|
|
230
|
+
fs.writeFileSync(blocker, "");
|
|
224
231
|
try {
|
|
225
232
|
const result = resolveSentinelPath({
|
|
226
233
|
platformPkgDir: undefined,
|
|
227
234
|
packageName: "@fallow-cli/darwin-arm64",
|
|
228
|
-
env: { FALLOW_VERIFY_CACHE_DIR: "
|
|
235
|
+
env: { FALLOW_VERIFY_CACHE_DIR: path.join(blocker, "inside", "a", "file") },
|
|
229
236
|
homedir: homeDir,
|
|
230
237
|
platform: "darwin",
|
|
231
238
|
});
|
package/scripts/verify-binary.js
CHANGED
|
@@ -293,15 +293,16 @@ function binaryTargetsForPlatform(platformId) {
|
|
|
293
293
|
// and tests can synthesize a Windows verify without running on Windows.
|
|
294
294
|
const isWindows = typeof platformId === "string" && platformId.startsWith("win32");
|
|
295
295
|
const ext = isWindows ? ".exe" : "";
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
296
|
+
// The platform package ships the multicall CLI plus the local-only
|
|
297
|
+
// similar-code provider. Both are signed and digest-pinned. Verifying them
|
|
298
|
+
// together keeps the Rust sibling lookup free of project or PATH overrides.
|
|
299
|
+
return [
|
|
300
|
+
{ binary: `fallow${ext}`, asset: `fallow-${platformId}${ext}` },
|
|
301
|
+
{
|
|
302
|
+
binary: `fallow-similar-code${ext}`,
|
|
303
|
+
asset: `fallow-similar-code-${platformId}${ext}`,
|
|
304
|
+
},
|
|
305
|
+
];
|
|
305
306
|
}
|
|
306
307
|
|
|
307
308
|
function isSkipRequested() {
|
|
@@ -239,11 +239,21 @@ test("verifyBinaryAt uses the embedded production public key", () => {
|
|
|
239
239
|
}
|
|
240
240
|
});
|
|
241
241
|
|
|
242
|
+
// Mirror binaryTargetsForPlatform: the suffix comes from the platformId under
|
|
243
|
+
// test, never from the live process.platform. A `dirOverride` run without an
|
|
244
|
+
// explicit platformId is labelled "test-platform" by
|
|
245
|
+
// resolvePlatformPackageForVerify, so the binary it looks for is plain `fallow`
|
|
246
|
+
// on every host -- a fixture keyed off process.platform writes `fallow.exe` on a
|
|
247
|
+
// Windows host and the verify then finds nothing.
|
|
248
|
+
function extForPlatformId(platformId) {
|
|
249
|
+
return typeof platformId === "string" && platformId.startsWith("win32") ? ".exe" : "";
|
|
250
|
+
}
|
|
251
|
+
|
|
242
252
|
function makePlatformDir(privateKey, options) {
|
|
243
253
|
const opts = options || {};
|
|
244
254
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fallow-vbtest-"));
|
|
245
|
-
const ext =
|
|
246
|
-
for (const base of ["fallow"]) {
|
|
255
|
+
const ext = extForPlatformId(opts.platformId);
|
|
256
|
+
for (const base of ["fallow", "fallow-similar-code"]) {
|
|
247
257
|
const binaryPath = path.join(dir, `${base}${ext}`);
|
|
248
258
|
const content = Buffer.from(`mock ${base} contents`);
|
|
249
259
|
fs.writeFileSync(binaryPath, content);
|
|
@@ -328,6 +338,42 @@ test("verifyInstalled with dirOverride returns ok when every binary verifies", a
|
|
|
328
338
|
assert.equal(result.package, "<override>");
|
|
329
339
|
});
|
|
330
340
|
|
|
341
|
+
// binaryTargetsForPlatform reads windows-ness off the platformId so a Windows
|
|
342
|
+
// verify can be synthesized anywhere. Nothing exercised that, which left the
|
|
343
|
+
// `.exe` target unverified on Linux CI and unverified on Windows too.
|
|
344
|
+
test("verifyInstalled verifies the .exe target for a win32 platformId on any host", async (t) => {
|
|
345
|
+
const { privateKey, rawPub } = makeKeypair();
|
|
346
|
+
const platformId = "win32-x64-msvc";
|
|
347
|
+
const dir = makePlatformDir(privateKey, { platformId });
|
|
348
|
+
t.after(() => cleanup(dir));
|
|
349
|
+
assert.ok(fs.existsSync(path.join(dir, "fallow.exe")), "fixture must write the .exe target");
|
|
350
|
+
|
|
351
|
+
const result = await verifyInstalled({
|
|
352
|
+
dirOverride: dir,
|
|
353
|
+
platformId,
|
|
354
|
+
verifyFn: (p) => _verifyWithKey(p, rawPub),
|
|
355
|
+
digestProvider: makeDigestProvider(dir),
|
|
356
|
+
});
|
|
357
|
+
assert.equal(result.ok, true);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("verifyInstalled reports the .exe name when a win32 signature is absent", async (t) => {
|
|
361
|
+
const { privateKey, rawPub } = makeKeypair();
|
|
362
|
+
const platformId = "win32-arm64-msvc";
|
|
363
|
+
const dir = makePlatformDir(privateKey, { platformId, skipSigFor: "fallow" });
|
|
364
|
+
t.after(() => cleanup(dir));
|
|
365
|
+
|
|
366
|
+
const result = await verifyInstalled({
|
|
367
|
+
dirOverride: dir,
|
|
368
|
+
platformId,
|
|
369
|
+
verifyFn: (p) => _verifyWithKey(p, rawPub),
|
|
370
|
+
digestProvider: makeDigestProvider(dir),
|
|
371
|
+
});
|
|
372
|
+
assert.equal(result.ok, false);
|
|
373
|
+
assert.equal(result.code, "sig-missing");
|
|
374
|
+
assert.match(result.message, /fallow\.exe/);
|
|
375
|
+
});
|
|
376
|
+
|
|
331
377
|
test("verifyInstalled resolves a global npm install from the fallow package directory", async (t) => {
|
|
332
378
|
const pkg = currentPlatformPackage();
|
|
333
379
|
if (!pkg) {
|
|
@@ -349,7 +395,7 @@ test("verifyInstalled resolves a global npm install from the fallow package dire
|
|
|
349
395
|
);
|
|
350
396
|
|
|
351
397
|
const ext = process.platform === "win32" ? ".exe" : "";
|
|
352
|
-
for (const base of ["fallow"]) {
|
|
398
|
+
for (const base of ["fallow", "fallow-similar-code"]) {
|
|
353
399
|
const binaryPath = path.join(platformDir, `${base}${ext}`);
|
|
354
400
|
const content = Buffer.from(`global install ${base}`);
|
|
355
401
|
fs.writeFileSync(binaryPath, content);
|
|
@@ -460,10 +506,10 @@ test("verifyInstalled honors FALLOW_SKIP_BINARY_VERIFY", async (t) => {
|
|
|
460
506
|
assert.equal(result.skipped, true);
|
|
461
507
|
});
|
|
462
508
|
|
|
463
|
-
function computeDigestsForDir(dir) {
|
|
464
|
-
const ext =
|
|
509
|
+
function computeDigestsForDir(dir, platformId) {
|
|
510
|
+
const ext = extForPlatformId(platformId);
|
|
465
511
|
const out = {};
|
|
466
|
-
for (const base of ["fallow"]) {
|
|
512
|
+
for (const base of ["fallow", "fallow-similar-code"]) {
|
|
467
513
|
const fileName = `${base}${ext}`;
|
|
468
514
|
const full = path.join(dir, fileName);
|
|
469
515
|
out[fileName] =
|
|
@@ -549,7 +595,7 @@ test("verifyInstalled falls back to the provider when the embedded digest is mis
|
|
|
549
595
|
},
|
|
550
596
|
});
|
|
551
597
|
assert.equal(result.ok, true);
|
|
552
|
-
assert.equal(providerCalls,
|
|
598
|
+
assert.equal(providerCalls, 2);
|
|
553
599
|
});
|
|
554
600
|
|
|
555
601
|
test("verifyInstalled falls back to the provider when fallowDigests is partial / malformed", async (t) => {
|
|
@@ -573,15 +619,15 @@ test("verifyInstalled falls back to the provider when fallowDigests is partial /
|
|
|
573
619
|
},
|
|
574
620
|
});
|
|
575
621
|
assert.equal(result.ok, true);
|
|
576
|
-
//
|
|
577
|
-
assert.equal(providerCalls,
|
|
622
|
+
// Both shipped binaries fall back because their embedded entries are absent or malformed.
|
|
623
|
+
assert.equal(providerCalls, 2);
|
|
578
624
|
});
|
|
579
625
|
|
|
580
626
|
test("verifyInstalled returns digest-mismatch when the embedded digest disagrees with the binary", async (t) => {
|
|
581
627
|
const { privateKey, rawPub } = makeKeypair();
|
|
582
628
|
const dir = makePlatformDir(privateKey);
|
|
583
629
|
t.after(() => cleanup(dir));
|
|
584
|
-
const ext =
|
|
630
|
+
const ext = extForPlatformId();
|
|
585
631
|
writeManifest(dir, {
|
|
586
632
|
name: "@fallow-cli/x",
|
|
587
633
|
version: "1.0.0",
|