fallow 2.80.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.
- package/bin/fallow +2 -47
- package/bin/fallow-lsp +2 -47
- package/bin/fallow-mcp +2 -47
- package/package.json +13 -11
- package/schema.json +7 -2
- package/scripts/lazy-verify.js +351 -0
- package/scripts/lazy-verify.test.js +573 -0
- package/scripts/platform-package.js +8 -8
- package/scripts/platform-package.test.js +17 -17
- package/scripts/run-binary.js +122 -0
- package/scripts/sentinel-path.js +165 -0
- package/scripts/sentinel-path.test.js +236 -0
- package/scripts/verify-binary.js +318 -130
- package/scripts/verify-binary.test.js +309 -156
- package/skills/fallow/references/cli-reference.md +1 -1
- package/types/output-contract.d.ts +22 -5
- package/scripts/postinstall.js +0 -63
package/scripts/verify-binary.js
CHANGED
|
@@ -24,13 +24,13 @@
|
|
|
24
24
|
//
|
|
25
25
|
// No external dependencies: uses node:crypto and node:fs only.
|
|
26
26
|
|
|
27
|
-
const crypto = require(
|
|
28
|
-
const fs = require(
|
|
29
|
-
const https = require(
|
|
30
|
-
const path = require(
|
|
31
|
-
const { getPlatformPackage } = require(
|
|
27
|
+
const crypto = require("node:crypto");
|
|
28
|
+
const fs = require("node:fs");
|
|
29
|
+
const https = require("node:https");
|
|
30
|
+
const path = require("node:path");
|
|
31
|
+
const { getPlatformPackage } = require("./platform-package");
|
|
32
32
|
|
|
33
|
-
const GITHUB_REPO =
|
|
33
|
+
const GITHUB_REPO = "fallow-rs/fallow";
|
|
34
34
|
const DIGEST_TIMEOUT_MS = 10000;
|
|
35
35
|
|
|
36
36
|
// 32-byte Ed25519 public key, identical to BINARY_SIGNING_PUBLIC_KEY in
|
|
@@ -39,8 +39,8 @@ const DIGEST_TIMEOUT_MS = 10000;
|
|
|
39
39
|
// works offline and cannot be silently downgraded by tampering with the network
|
|
40
40
|
// path.
|
|
41
41
|
const EMBEDDED_PUBLIC_KEY = Buffer.from([
|
|
42
|
-
131, 78, 111, 215, 115, 51, 230, 238, 223, 119, 147, 71, 199, 16, 172, 180, 3, 210, 216, 35,
|
|
43
|
-
|
|
42
|
+
131, 78, 111, 215, 115, 51, 230, 238, 223, 119, 147, 71, 199, 16, 172, 180, 3, 210, 216, 35, 77,
|
|
43
|
+
85, 159, 94, 215, 200, 126, 85, 42, 222, 11, 209,
|
|
44
44
|
]);
|
|
45
45
|
|
|
46
46
|
// SPKI DER header for Ed25519 (RFC 8410). 12 bytes prepended to a 32-byte raw
|
|
@@ -50,14 +50,14 @@ const ED25519_SPKI_HEADER = Buffer.from([
|
|
|
50
50
|
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
|
51
51
|
]);
|
|
52
52
|
|
|
53
|
-
const SKIP_ENV =
|
|
53
|
+
const SKIP_ENV = "FALLOW_SKIP_BINARY_VERIFY";
|
|
54
54
|
|
|
55
55
|
function buildPublicKey(rawPubKey) {
|
|
56
56
|
if (!Buffer.isBuffer(rawPubKey) || rawPubKey.length !== 32) {
|
|
57
|
-
throw new Error(
|
|
57
|
+
throw new Error("expected 32-byte raw Ed25519 public key");
|
|
58
58
|
}
|
|
59
59
|
const spki = Buffer.concat([ED25519_SPKI_HEADER, rawPubKey]);
|
|
60
|
-
return crypto.createPublicKey({ key: spki, format:
|
|
60
|
+
return crypto.createPublicKey({ key: spki, format: "der", type: "spki" });
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function _verifyWithKey(binaryPath, rawPubKey) {
|
|
@@ -65,10 +65,14 @@ function _verifyWithKey(binaryPath, rawPubKey) {
|
|
|
65
65
|
try {
|
|
66
66
|
binaryBytes = fs.readFileSync(binaryPath);
|
|
67
67
|
} catch (err) {
|
|
68
|
-
if (err && err.code ===
|
|
69
|
-
return { ok: false, code:
|
|
68
|
+
if (err && err.code === "ENOENT") {
|
|
69
|
+
return { ok: false, code: "binary-missing", message: `binary not found at ${binaryPath}` };
|
|
70
70
|
}
|
|
71
|
-
return {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
code: "read-error",
|
|
74
|
+
message: `cannot read binary at ${binaryPath}: ${err.message}`,
|
|
75
|
+
};
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
const sigPath = `${binaryPath}.sig`;
|
|
@@ -76,31 +80,47 @@ function _verifyWithKey(binaryPath, rawPubKey) {
|
|
|
76
80
|
try {
|
|
77
81
|
signature = fs.readFileSync(sigPath);
|
|
78
82
|
} catch (err) {
|
|
79
|
-
if (err && err.code ===
|
|
80
|
-
return { ok: false, code:
|
|
83
|
+
if (err && err.code === "ENOENT") {
|
|
84
|
+
return { ok: false, code: "sig-missing", message: `signature not found at ${sigPath}` };
|
|
81
85
|
}
|
|
82
|
-
return {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
code: "read-error",
|
|
89
|
+
message: `cannot read signature at ${sigPath}: ${err.message}`,
|
|
90
|
+
};
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
if (signature.length !== 64) {
|
|
86
|
-
return {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
code: "sig-invalid",
|
|
97
|
+
message: `signature at ${sigPath} has unexpected length ${signature.length} (want 64)`,
|
|
98
|
+
};
|
|
87
99
|
}
|
|
88
100
|
|
|
89
101
|
let publicKey;
|
|
90
102
|
try {
|
|
91
103
|
publicKey = buildPublicKey(rawPubKey);
|
|
92
104
|
} catch (err) {
|
|
93
|
-
return {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
code: "key-invalid",
|
|
108
|
+
message: `cannot construct public key: ${err.message}`,
|
|
109
|
+
};
|
|
94
110
|
}
|
|
95
111
|
|
|
96
112
|
let valid;
|
|
97
113
|
try {
|
|
98
114
|
valid = crypto.verify(null, binaryBytes, publicKey, signature);
|
|
99
115
|
} catch (err) {
|
|
100
|
-
return { ok: false, code:
|
|
116
|
+
return { ok: false, code: "sig-invalid", message: `crypto.verify threw: ${err.message}` };
|
|
101
117
|
}
|
|
102
118
|
if (!valid) {
|
|
103
|
-
return {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
code: "sig-invalid",
|
|
122
|
+
message: `Ed25519 verification failed for ${binaryPath}`,
|
|
123
|
+
};
|
|
104
124
|
}
|
|
105
125
|
return { ok: true };
|
|
106
126
|
}
|
|
@@ -110,29 +130,40 @@ function verifyBinaryAt(binaryPath) {
|
|
|
110
130
|
}
|
|
111
131
|
|
|
112
132
|
function normalizeDigest(digest) {
|
|
113
|
-
if (typeof digest !==
|
|
133
|
+
if (typeof digest !== "string") {
|
|
114
134
|
return null;
|
|
115
135
|
}
|
|
116
136
|
const lower = digest.trim().toLowerCase();
|
|
117
|
-
const value = lower.startsWith(
|
|
137
|
+
const value = lower.startsWith("sha256:") ? lower.slice("sha256:".length) : lower;
|
|
118
138
|
return /^[0-9a-f]{64}$/.test(value) ? value : null;
|
|
119
139
|
}
|
|
120
140
|
|
|
121
141
|
function sha256Hex(binaryPath) {
|
|
122
142
|
try {
|
|
123
|
-
return {
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
digest: crypto.createHash("sha256").update(fs.readFileSync(binaryPath)).digest("hex"),
|
|
146
|
+
};
|
|
124
147
|
} catch (err) {
|
|
125
|
-
if (err && err.code ===
|
|
126
|
-
return { ok: false, code:
|
|
148
|
+
if (err && err.code === "ENOENT") {
|
|
149
|
+
return { ok: false, code: "binary-missing", message: `binary not found at ${binaryPath}` };
|
|
127
150
|
}
|
|
128
|
-
return {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
code: "read-error",
|
|
154
|
+
message: `cannot read binary at ${binaryPath}: ${err.message}`,
|
|
155
|
+
};
|
|
129
156
|
}
|
|
130
157
|
}
|
|
131
158
|
|
|
132
159
|
function verifyDigestAt(binaryPath, expectedDigest) {
|
|
133
160
|
const normalized = normalizeDigest(expectedDigest);
|
|
134
161
|
if (!normalized) {
|
|
135
|
-
return {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
code: "digest-invalid",
|
|
165
|
+
message: `invalid SHA-256 digest '${expectedDigest}'`,
|
|
166
|
+
};
|
|
136
167
|
}
|
|
137
168
|
|
|
138
169
|
const actual = sha256Hex(binaryPath);
|
|
@@ -142,7 +173,7 @@ function verifyDigestAt(binaryPath, expectedDigest) {
|
|
|
142
173
|
if (actual.digest !== normalized) {
|
|
143
174
|
return {
|
|
144
175
|
ok: false,
|
|
145
|
-
code:
|
|
176
|
+
code: "digest-mismatch",
|
|
146
177
|
message: `SHA-256 digest mismatch for ${binaryPath}: got ${actual.digest}, want ${normalized}`,
|
|
147
178
|
};
|
|
148
179
|
}
|
|
@@ -153,7 +184,7 @@ function httpsJson(url, redirects = 0) {
|
|
|
153
184
|
return new Promise((resolve, reject) => {
|
|
154
185
|
const request = https.get(
|
|
155
186
|
url,
|
|
156
|
-
{ headers: {
|
|
187
|
+
{ headers: { "User-Agent": "fallow-binary-verifier" }, timeout: DIGEST_TIMEOUT_MS },
|
|
157
188
|
(response) => {
|
|
158
189
|
if (
|
|
159
190
|
response.statusCode &&
|
|
@@ -168,11 +199,15 @@ function httpsJson(url, redirects = 0) {
|
|
|
168
199
|
}
|
|
169
200
|
|
|
170
201
|
const chunks = [];
|
|
171
|
-
response.on(
|
|
172
|
-
response.on(
|
|
173
|
-
const body = Buffer.concat(chunks).toString(
|
|
202
|
+
response.on("data", (chunk) => chunks.push(chunk));
|
|
203
|
+
response.on("end", () => {
|
|
204
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
174
205
|
if (!response.statusCode || response.statusCode >= 400) {
|
|
175
|
-
reject(
|
|
206
|
+
reject(
|
|
207
|
+
new Error(
|
|
208
|
+
`GitHub release API returned HTTP ${response.statusCode || "unknown"}: ${body.slice(0, 200)}`,
|
|
209
|
+
),
|
|
210
|
+
);
|
|
176
211
|
return;
|
|
177
212
|
}
|
|
178
213
|
try {
|
|
@@ -183,8 +218,10 @@ function httpsJson(url, redirects = 0) {
|
|
|
183
218
|
});
|
|
184
219
|
},
|
|
185
220
|
);
|
|
186
|
-
request.on(
|
|
187
|
-
|
|
221
|
+
request.on("timeout", () =>
|
|
222
|
+
request.destroy(new Error(`timed out after ${DIGEST_TIMEOUT_MS}ms`)),
|
|
223
|
+
);
|
|
224
|
+
request.on("error", reject);
|
|
188
225
|
});
|
|
189
226
|
}
|
|
190
227
|
|
|
@@ -226,28 +263,32 @@ function platformPackageDir(pkg, resolveFrom) {
|
|
|
226
263
|
// when the manifest does not exist, cannot be parsed, lacks the field, or
|
|
227
264
|
// the value is malformed. Refs #597.
|
|
228
265
|
function readEmbeddedDigest(manifestPath, binaryFileName) {
|
|
229
|
-
if (typeof manifestPath !==
|
|
266
|
+
if (typeof manifestPath !== "string" || manifestPath.length === 0) {
|
|
230
267
|
return null;
|
|
231
268
|
}
|
|
232
269
|
let manifest;
|
|
233
270
|
try {
|
|
234
|
-
manifest = JSON.parse(fs.readFileSync(manifestPath,
|
|
271
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
235
272
|
} catch {
|
|
236
273
|
return null;
|
|
237
274
|
}
|
|
238
|
-
if (!manifest || typeof manifest !==
|
|
275
|
+
if (!manifest || typeof manifest !== "object") {
|
|
239
276
|
return null;
|
|
240
277
|
}
|
|
241
278
|
const digests = manifest.fallowDigests;
|
|
242
|
-
if (!digests || typeof digests !==
|
|
279
|
+
if (!digests || typeof digests !== "object") {
|
|
243
280
|
return null;
|
|
244
281
|
}
|
|
245
282
|
return normalizeDigest(digests[binaryFileName]);
|
|
246
283
|
}
|
|
247
284
|
|
|
248
285
|
function binaryTargetsForPlatform(platformId) {
|
|
249
|
-
|
|
250
|
-
|
|
286
|
+
// Derive the `.exe` suffix from the platformId, not from the live
|
|
287
|
+
// process.platform. Production callers pass `<platform>-<arch>...` strings
|
|
288
|
+
// that already encode windows-ness (`win32-x64-msvc`, `win32-arm64-msvc`),
|
|
289
|
+
// and tests can synthesize a Windows verify without running on Windows.
|
|
290
|
+
const isWindows = typeof platformId === "string" && platformId.startsWith("win32");
|
|
291
|
+
const ext = isWindows ? ".exe" : "";
|
|
251
292
|
return [
|
|
252
293
|
{ binary: `fallow${ext}`, asset: `fallow-${platformId}${ext}` },
|
|
253
294
|
{ binary: `fallow-lsp${ext}`, asset: `fallow-lsp-${platformId}${ext}` },
|
|
@@ -257,7 +298,144 @@ function binaryTargetsForPlatform(platformId) {
|
|
|
257
298
|
|
|
258
299
|
function isSkipRequested() {
|
|
259
300
|
const v = process.env[SKIP_ENV];
|
|
260
|
-
return v ===
|
|
301
|
+
return v === "1" || v === "true" || v === "yes";
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function currentPlatformPackageName() {
|
|
305
|
+
if (process.platform !== "linux") {
|
|
306
|
+
return getPlatformPackage(process.platform, process.arch);
|
|
307
|
+
}
|
|
308
|
+
let libcFamily;
|
|
309
|
+
try {
|
|
310
|
+
libcFamily = require("detect-libc").familySync();
|
|
311
|
+
} catch {
|
|
312
|
+
libcFamily = undefined;
|
|
313
|
+
}
|
|
314
|
+
return getPlatformPackage(process.platform, process.arch, libcFamily);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function readManifestForPackage(manifestPath, pkg) {
|
|
318
|
+
let version;
|
|
319
|
+
try {
|
|
320
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
321
|
+
version = manifest.version;
|
|
322
|
+
} catch (err) {
|
|
323
|
+
return {
|
|
324
|
+
ok: false,
|
|
325
|
+
code: "manifest-invalid",
|
|
326
|
+
message: `cannot read platform package manifest for ${pkg}: ${err.message}`,
|
|
327
|
+
package: pkg,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
if (typeof version !== "string" || !version.trim()) {
|
|
331
|
+
return {
|
|
332
|
+
ok: false,
|
|
333
|
+
code: "manifest-invalid",
|
|
334
|
+
message: `platform package ${pkg} does not declare a version`,
|
|
335
|
+
package: pkg,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
return { ok: true, version };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Shared platform-package resolution used by both the async and sync verify
|
|
342
|
+
// paths. Returns either { ok: true, dir, manifestPath, pkg, version, platformId }
|
|
343
|
+
// or { ok: false, code, message, package? } matching verifyInstalled's error
|
|
344
|
+
// shape. dirOverride is a test-only knob; production callers must not set it.
|
|
345
|
+
function resolvePlatformPackageForVerify(opts) {
|
|
346
|
+
if (typeof opts.dirOverride === "string" && opts.dirOverride.length > 0) {
|
|
347
|
+
return {
|
|
348
|
+
ok: true,
|
|
349
|
+
dir: opts.dirOverride,
|
|
350
|
+
manifestPath: path.join(opts.dirOverride, "package.json"),
|
|
351
|
+
pkg: "<override>",
|
|
352
|
+
version: opts.version || "0.0.0",
|
|
353
|
+
platformId: opts.platformId || "test-platform",
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const pkg = currentPlatformPackageName();
|
|
358
|
+
if (!pkg) {
|
|
359
|
+
return {
|
|
360
|
+
ok: false,
|
|
361
|
+
code: "platform-unsupported",
|
|
362
|
+
message: `no prebuilt binary for ${process.platform}-${process.arch}`,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
let dir;
|
|
367
|
+
let manifestPath;
|
|
368
|
+
try {
|
|
369
|
+
({ dir, manifestPath } = platformPackageDir(pkg, opts.resolveFrom));
|
|
370
|
+
} catch (err) {
|
|
371
|
+
return {
|
|
372
|
+
ok: false,
|
|
373
|
+
code: "platform-package-missing",
|
|
374
|
+
message: `platform package ${pkg} not installed: ${err.message}`,
|
|
375
|
+
package: pkg,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const manifest = readManifestForPackage(manifestPath, pkg);
|
|
380
|
+
if (!manifest.ok) return manifest;
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
ok: true,
|
|
384
|
+
dir,
|
|
385
|
+
manifestPath,
|
|
386
|
+
pkg,
|
|
387
|
+
version: manifest.version,
|
|
388
|
+
platformId: pkg.replace(/^@fallow-cli\//, ""),
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Verify one binary against its sig + expected SHA-256. Used by both the
|
|
393
|
+
// sync and async verify-installed entry points; the digest provider may be
|
|
394
|
+
// sync (returns string) or async (returns Promise<string>), and the loop body
|
|
395
|
+
// awaits the value regardless. Keeps the outer functions a flat for-loop so
|
|
396
|
+
// cyclomatic + cognitive complexity stays low.
|
|
397
|
+
async function verifyOneBinary(target, dir, pkg, manifestPath, verifyFn, digestProvider) {
|
|
398
|
+
const binaryPath = path.join(dir, target.binary);
|
|
399
|
+
const sigResult = verifyFn(binaryPath);
|
|
400
|
+
if (!sigResult.ok) {
|
|
401
|
+
return { ...sigResult, binary: binaryPath, package: pkg };
|
|
402
|
+
}
|
|
403
|
+
// Prefer the digest embedded in the platform package's package.json
|
|
404
|
+
// (written at release time by `npm-prep`). Falling back to the GitHub
|
|
405
|
+
// release API only when no embedded digest is present preserves
|
|
406
|
+
// backwards compatibility with platform packages published before #597.
|
|
407
|
+
let expectedDigest = manifestPath ? readEmbeddedDigest(manifestPath, target.binary) : null;
|
|
408
|
+
if (!expectedDigest && digestProvider) {
|
|
409
|
+
try {
|
|
410
|
+
expectedDigest = await digestProvider({
|
|
411
|
+
assetName: target.asset,
|
|
412
|
+
binaryPath,
|
|
413
|
+
packageName: pkg,
|
|
414
|
+
});
|
|
415
|
+
} catch (err) {
|
|
416
|
+
return {
|
|
417
|
+
ok: false,
|
|
418
|
+
code: "digest-unavailable",
|
|
419
|
+
message: `cannot load SHA-256 digest for ${target.asset}: ${err.message}`,
|
|
420
|
+
binary: binaryPath,
|
|
421
|
+
package: pkg,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (!expectedDigest) {
|
|
426
|
+
return {
|
|
427
|
+
ok: false,
|
|
428
|
+
code: "digest-unavailable",
|
|
429
|
+
message: "no digest",
|
|
430
|
+
binary: binaryPath,
|
|
431
|
+
package: pkg,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
const digestResult = verifyDigestAt(binaryPath, expectedDigest);
|
|
435
|
+
if (!digestResult.ok) {
|
|
436
|
+
return { ...digestResult, binary: binaryPath, package: pkg };
|
|
437
|
+
}
|
|
438
|
+
return { ok: true };
|
|
261
439
|
}
|
|
262
440
|
|
|
263
441
|
// Locate the platform package the wrapper would use at runtime and verify
|
|
@@ -284,109 +462,119 @@ async function verifyInstalled(options) {
|
|
|
284
462
|
return { ok: true, skipped: true, reason: `${SKIP_ENV} is set` };
|
|
285
463
|
}
|
|
286
464
|
|
|
287
|
-
const verify = typeof opts.verifyFn ===
|
|
288
|
-
const digestProvider =
|
|
289
|
-
|
|
290
|
-
|
|
465
|
+
const verify = typeof opts.verifyFn === "function" ? opts.verifyFn : verifyBinaryAt;
|
|
466
|
+
const digestProvider =
|
|
467
|
+
typeof opts.digestProvider === "function"
|
|
468
|
+
? opts.digestProvider
|
|
469
|
+
: ({ assetName, version }) => fetchReleaseDigest(version, assetName);
|
|
291
470
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
let version;
|
|
296
|
-
let platformId;
|
|
297
|
-
if (typeof opts.dirOverride === 'string' && opts.dirOverride.length > 0) {
|
|
298
|
-
dir = opts.dirOverride;
|
|
299
|
-
pkg = '<override>';
|
|
300
|
-
version = opts.version || '0.0.0';
|
|
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');
|
|
305
|
-
} else {
|
|
306
|
-
if (process.platform !== 'linux') {
|
|
307
|
-
pkg = getPlatformPackage(process.platform, process.arch);
|
|
308
|
-
} else {
|
|
309
|
-
let libcFamily;
|
|
310
|
-
try {
|
|
311
|
-
libcFamily = require('detect-libc').familySync();
|
|
312
|
-
} catch {
|
|
313
|
-
// detect-libc is a dependency, but tolerate its absence the same way
|
|
314
|
-
// postinstall.js already does.
|
|
315
|
-
libcFamily = undefined;
|
|
316
|
-
}
|
|
317
|
-
pkg = getPlatformPackage(process.platform, process.arch, libcFamily);
|
|
318
|
-
}
|
|
471
|
+
const resolved = resolvePlatformPackageForVerify(opts);
|
|
472
|
+
if (!resolved.ok) return resolved;
|
|
473
|
+
const { dir, manifestPath, pkg, version, platformId } = resolved;
|
|
319
474
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
475
|
+
// digestProvider here is always defined (the fallback above guarantees it),
|
|
476
|
+
// so verifyOneBinary always reaches a digest. Bind `version` into the
|
|
477
|
+
// provider so the per-binary loop body does not need to thread it.
|
|
478
|
+
const boundProvider = async (args) => digestProvider({ ...args, version });
|
|
323
479
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
480
|
+
for (const target of binaryTargetsForPlatform(platformId)) {
|
|
481
|
+
const result = await verifyOneBinary(target, dir, pkg, manifestPath, verify, boundProvider);
|
|
482
|
+
if (!result.ok) return result;
|
|
483
|
+
}
|
|
484
|
+
return { ok: true, package: pkg, version };
|
|
485
|
+
}
|
|
329
486
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
487
|
+
// Synchronous variant used by the lazy-verify first-run path in bin/fallow,
|
|
488
|
+
// bin/fallow-lsp, and bin/fallow-mcp. Matches verifyInstalled's result shape
|
|
489
|
+
// but never falls back to the GitHub Release API: callers that need network
|
|
490
|
+
// fallback must use the async verifyInstalled instead. This keeps bin-wrapper
|
|
491
|
+
// startup synchronous and bounded.
|
|
492
|
+
//
|
|
493
|
+
// options:
|
|
494
|
+
// allowSkipEnv - if false, ignore FALLOW_SKIP_BINARY_VERIFY. Default true.
|
|
495
|
+
// dirOverride - test-only directory containing binaries.
|
|
496
|
+
// verifyFn - replaces verifyBinaryAt for tests.
|
|
497
|
+
// digestProvider - sync function ({ assetName, binaryPath, packageName, version })
|
|
498
|
+
// returning a sha256 digest string or null. When absent and
|
|
499
|
+
// no embedded digest is present, returns a
|
|
500
|
+
// `digest-unavailable` error pointing the user at
|
|
501
|
+
// `npm install fallow@latest` (the embedded-digest field
|
|
502
|
+
// landed in 2.78.1 / #597; pre-#597 platform packages
|
|
503
|
+
// cannot be lazily verified). When supplied (tests), used
|
|
504
|
+
// in place of the embedded-digest read.
|
|
505
|
+
// resolveFrom - module resolution base for locating platform packages.
|
|
506
|
+
function verifyInstalledSync(options) {
|
|
507
|
+
const opts = options || {};
|
|
508
|
+
const skipEnvAllowed = opts.allowSkipEnv !== false;
|
|
509
|
+
if (skipEnvAllowed && isSkipRequested()) {
|
|
510
|
+
return { ok: true, skipped: true, reason: `${SKIP_ENV} is set` };
|
|
340
511
|
}
|
|
341
512
|
|
|
513
|
+
const verify = typeof opts.verifyFn === "function" ? opts.verifyFn : verifyBinaryAt;
|
|
514
|
+
const digestProvider = typeof opts.digestProvider === "function" ? opts.digestProvider : null;
|
|
515
|
+
|
|
516
|
+
const resolved = resolvePlatformPackageForVerify(opts);
|
|
517
|
+
if (!resolved.ok) return resolved;
|
|
518
|
+
const { dir, manifestPath, pkg, version, platformId } = resolved;
|
|
519
|
+
|
|
342
520
|
for (const target of binaryTargetsForPlatform(platformId)) {
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
if (!result.ok) {
|
|
346
|
-
return { ...result, binary: binaryPath, package: pkg };
|
|
347
|
-
}
|
|
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
|
-
}
|
|
375
|
-
}
|
|
376
|
-
const digestResult = verifyDigestAt(binaryPath, expectedDigest);
|
|
377
|
-
if (!digestResult.ok) {
|
|
378
|
-
return { ...digestResult, binary: binaryPath, package: pkg };
|
|
379
|
-
}
|
|
521
|
+
const result = verifyOneBinarySync(target, dir, pkg, manifestPath, verify, digestProvider);
|
|
522
|
+
if (!result.ok) return result;
|
|
380
523
|
}
|
|
381
524
|
return { ok: true, package: pkg, version };
|
|
382
525
|
}
|
|
383
526
|
|
|
527
|
+
// Sync counterpart to verifyOneBinary. Different from the async version in
|
|
528
|
+
// three ways: no `await`, the missing-embedded-digest path returns a clear
|
|
529
|
+
// actionable error pointing the user at `npm install fallow@latest` (since
|
|
530
|
+
// there is no network fallback in lazy mode), and the digestProvider is
|
|
531
|
+
// optional (tests inject one; production callers rely on the embedded digest).
|
|
532
|
+
function verifyOneBinarySync(target, dir, pkg, manifestPath, verifyFn, digestProvider) {
|
|
533
|
+
const binaryPath = path.join(dir, target.binary);
|
|
534
|
+
const sigResult = verifyFn(binaryPath);
|
|
535
|
+
if (!sigResult.ok) {
|
|
536
|
+
return { ...sigResult, binary: binaryPath, package: pkg };
|
|
537
|
+
}
|
|
538
|
+
let expectedDigest = manifestPath ? readEmbeddedDigest(manifestPath, target.binary) : null;
|
|
539
|
+
if (!expectedDigest && digestProvider) {
|
|
540
|
+
try {
|
|
541
|
+
expectedDigest = digestProvider({ assetName: target.asset, binaryPath, packageName: pkg });
|
|
542
|
+
} catch (err) {
|
|
543
|
+
return {
|
|
544
|
+
ok: false,
|
|
545
|
+
code: "digest-unavailable",
|
|
546
|
+
message: `cannot load SHA-256 digest for ${target.asset}: ${err.message}`,
|
|
547
|
+
binary: binaryPath,
|
|
548
|
+
package: pkg,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (!expectedDigest) {
|
|
553
|
+
return {
|
|
554
|
+
ok: false,
|
|
555
|
+
code: "digest-unavailable",
|
|
556
|
+
message:
|
|
557
|
+
`no embedded SHA-256 digest for ${target.binary} in ${pkg} ` +
|
|
558
|
+
`(platform package predates fallow 2.78.1). ` +
|
|
559
|
+
`Run \`npm install fallow@latest\` to refresh, or set ${SKIP_ENV}=1 ` +
|
|
560
|
+
`to bypass verification (logged once per process).`,
|
|
561
|
+
binary: binaryPath,
|
|
562
|
+
package: pkg,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
const digestResult = verifyDigestAt(binaryPath, expectedDigest);
|
|
566
|
+
if (!digestResult.ok) {
|
|
567
|
+
return { ...digestResult, binary: binaryPath, package: pkg };
|
|
568
|
+
}
|
|
569
|
+
return { ok: true };
|
|
570
|
+
}
|
|
571
|
+
|
|
384
572
|
module.exports = {
|
|
385
573
|
verifyBinaryAt,
|
|
386
574
|
verifyDigestAt,
|
|
387
575
|
verifyInstalled,
|
|
576
|
+
verifyInstalledSync,
|
|
388
577
|
_verifyWithKey,
|
|
389
|
-
fetchReleaseDigest,
|
|
390
578
|
normalizeDigest,
|
|
391
579
|
readEmbeddedDigest,
|
|
392
580
|
sha256Hex,
|