pi-courier 0.1.37 → 0.1.38

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 CHANGED
@@ -56,7 +56,7 @@ npm run build
56
56
  npm link # make the `pi-courier` command available globally
57
57
  ```
58
58
 
59
- **Do not use `--ignore-scripts`**: the Matrix E2EE library downloads its native binary via postinstall. On npm >= 11 the `allow-scripts` default may block that dependency's postinstall; `pi-courier`'s own postinstall self-checks for it and auto-downloads the missing native binary (one extra download on first install). If you still hit `Cannot find module '@matrix-org/matrix-sdk-crypto-nodejs-linux-x64-gnu'` (e.g. the auto-download was skipped), run manually:
59
+ **Do not use `--ignore-scripts`**: the Matrix E2EE library downloads its native binary via postinstall. On npm >= 11 the `allow-scripts` default may block that dependency's postinstall; `pi-courier`'s own postinstall self-checks for it and auto-downloads the missing native binary (one extra download on first install; since 0.1.38 the binary is cached locally and sha256-verified, so later updates skip the 21 MB download and tampered binaries are refused). If you still hit `Cannot find module '@matrix-org/matrix-sdk-crypto-nodejs-linux-x64-gnu'` (e.g. the auto-download was skipped), run manually:
60
60
 
61
61
  ```bash
62
62
  cd node_modules/@matrix-org/matrix-sdk-crypto-nodejs
package/README.zh-CN.md CHANGED
@@ -56,7 +56,7 @@ npm run build
56
56
  npm link # 让 `pi-courier` 命令全局可用
57
57
  ```
58
58
 
59
- **不要用 `--ignore-scripts`**:Matrix E2EE 库的 postinstall 会下载原生二进制。npm >= 11 的 `allow-scripts` 默认可能拦截该依赖的 postinstall;`pi-courier` 自己的 postinstall 会自检并**自动补下**缺失的原生二进制(首次安装多下载一次)。若仍报 `Cannot find module '@matrix-org/matrix-sdk-crypto-nodejs-linux-x64-gnu'`(比如自检被跳过),再手动补:
59
+ **不要用 `--ignore-scripts`**:Matrix E2EE 库的 postinstall 会下载原生二进制。npm >= 11 的 `allow-scripts` 默认可能拦截该依赖的 postinstall;`pi-courier` 自己的 postinstall 会自检并**自动补下**缺失的原生二进制(首次安装多下载一次;0.1.38 起副本缓存到本地并做 sha256 校验,后续更新免重下,校验不符的库会被拒绝加载)。若仍报 `Cannot find module '@matrix-org/matrix-sdk-crypto-nodejs-linux-x64-gnu'`(比如自检被跳过),再手动补:
60
60
 
61
61
  ```bash
62
62
  cd node_modules/@matrix-org/matrix-sdk-crypto-nodejs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-courier",
3
- "version": "0.1.37",
3
+ "version": "0.1.38",
4
4
  "description": "Run pi coding agent from Matrix. Slash commands, skills and prompts fully work from messengers via the RPC protocol.",
5
5
  "type": "module",
6
6
  "main": "dist/standalone.js",
@@ -0,0 +1,3 @@
1
+ {
2
+ "0.4.0/matrix-sdk-crypto.linux-x64-gnu": "7078d4dd987128f79afd192c4f621f64adece37e43f2a8756f1d0a9f034de718"
3
+ }
@@ -4,6 +4,42 @@ export interface EnsureResult {
4
4
  ok: boolean;
5
5
  downloaded?: boolean;
6
6
  reason?: string;
7
+ /** True when the binding was restored from the local cache (no download). */
8
+ restoredFromCache?: boolean;
9
+ /** How the binary's sha256 was checked: manifest match, TOFU accept, or rejected. */
10
+ verified?: "manifest" | "tofu" | "rejected";
11
+ }
12
+
13
+ /** Options for ensureCryptoNative(); all optional, defaulting to real resolution. */
14
+ export interface EnsureCryptoNativeOptions {
15
+ cryptoDir?: string | null;
16
+ basename?: string | null;
17
+ cacheRoot?: string;
18
+ manifestPath?: string;
19
+ runDownloader?: (cryptoDir: string) => void;
20
+ }
21
+
22
+ /** Cache entry layout under the cache root. */
23
+ export interface CacheEntryPaths {
24
+ dir: string;
25
+ nodePath: string;
26
+ shaPath: string;
27
+ }
28
+
29
+ /** Result of evaluating a cache entry (readCacheEntry). */
30
+ export interface CacheEntryResult {
31
+ hit: boolean;
32
+ nodePath?: string;
33
+ sha256?: string;
34
+ verified?: "manifest" | "tofu";
35
+ reason?: "absent" | "unreadable" | "sidecar-missing" | "sidecar-mismatch" | "manifest-mismatch";
36
+ }
37
+
38
+ /** Verdict of verifyDownloadedBinding against the sha256 manifest. */
39
+ export interface VerifyDownloadedResult {
40
+ verdict: "manifest-match" | "manifest-mismatch" | "manifest-miss";
41
+ observed: string;
42
+ expected: string | null;
7
43
  }
8
44
 
9
45
  export function nativeBindingBasename(): string | null;
@@ -16,4 +52,51 @@ export function isNativeBindingMissing(cryptoDir: string, basename: string | nul
16
52
 
17
53
  export function runNativeDownloader(cryptoDir: string): void;
18
54
 
19
- export function ensureCryptoNative(): EnsureResult;
55
+ export function defaultCacheRoot(
56
+ env?: Record<string, string | undefined>,
57
+ home?: string
58
+ ): string;
59
+
60
+ export function defaultManifestPath(): string;
61
+
62
+ export function cacheEntryPaths(
63
+ cacheRoot: string,
64
+ version: string,
65
+ basename: string
66
+ ): CacheEntryPaths;
67
+
68
+ export function sha256FileSync(filePath: string): string;
69
+
70
+ export function readCryptoPackageVersion(cryptoDir: string): string | null;
71
+
72
+ /** Returns {} on missing/malformed manifest (warns); never throws. */
73
+ export function readManifest(manifestPath: string): Record<string, string>;
74
+
75
+ export function readCacheEntry(
76
+ cacheRoot: string,
77
+ version: string,
78
+ basename: string,
79
+ manifest: Record<string, string>
80
+ ): CacheEntryResult;
81
+
82
+ export function writeCacheEntry(
83
+ cacheRoot: string,
84
+ version: string,
85
+ basename: string,
86
+ sourceNodePath: string,
87
+ observedSha256: string
88
+ ): void;
89
+
90
+ export function restoreBindingFromCache(
91
+ cryptoDir: string,
92
+ basename: string,
93
+ cacheNodePath: string
94
+ ): string;
95
+
96
+ export function verifyDownloadedBinding(
97
+ nodePath: string,
98
+ manifestKey: string | null,
99
+ manifest: Record<string, string>
100
+ ): VerifyDownloadedResult;
101
+
102
+ export function ensureCryptoNative(options?: EnsureCryptoNativeOptions): EnsureResult;
@@ -18,15 +18,55 @@
18
18
  * present before first run. Any failure here degrades to a warning; it never
19
19
  * fails the install (the bridge can still run without E2EE crypto).
20
20
  *
21
+ * LOCAL CACHE + SHA256 VERIFICATION (issue #48):
22
+ *
23
+ * Every `npm update` reinstalls the dependency tree and wipes the freshly
24
+ * downloaded .node, forcing a ~21MB re-download (≈1min behind slow proxies).
25
+ * To avoid that, a verified copy is kept under
26
+ * `$XDG_CACHE_HOME/pi-courier/native-crypto/<crypto version>/<basename>.node`
27
+ * (default `~/.cache/pi-courier/native-crypto/...`) with a `<basename>.sha256`
28
+ * sidecar recording the observed digest. When a later install finds the
29
+ * binding missing, the cache is consulted first (sidecar + manifest both
30
+ * verified before use) and restored with zero network traffic.
31
+ *
32
+ * Integrity: binaries are loaded by Node as native code, so a tampered
33
+ * download is arbitrary code execution. Every accepted binary's sha256 must
34
+ * match `scripts/crypto-native-hashes.json` (keyed `<version>/<basename>`).
35
+ * A listed version with a mismatching digest is DELETED and E2EE degrades —
36
+ * the binary is never loaded. An unlisted version is accepted TOFU-style with
37
+ * a loud warning asking the maintainer to record the digest. Cache entries
38
+ * whose sidecar disagrees with their bytes are treated as absent.
39
+ *
40
+ * All functions are pure-ish (explicit path/manifest params, injectable
41
+ * downloader) so tests can exercise every branch in temp dirs without network.
42
+ *
21
43
  * Usage (from package root): node scripts/ensure-crypto-native.mjs
22
44
  */
23
45
 
24
46
  import { execFileSync } from "node:child_process";
25
- import { existsSync } from "node:fs";
47
+ import { createHash } from "node:crypto";
48
+ import {
49
+ chmodSync,
50
+ copyFileSync,
51
+ existsSync,
52
+ mkdirSync,
53
+ readFileSync,
54
+ renameSync,
55
+ rmSync,
56
+ writeFileSync,
57
+ } from "node:fs";
26
58
  import { createRequire } from "node:module";
59
+ import { homedir } from "node:os";
60
+ import { join } from "node:path";
61
+ import { fileURLToPath } from "node:url";
27
62
 
28
63
  const require = createRequire(import.meta.url);
29
64
 
65
+ const LOG_PREFIX = "[pi-courier]";
66
+ const CACHE_SUBDIR = join("pi-courier", "native-crypto");
67
+ const MANIFEST_FILENAME = "crypto-native-hashes.json";
68
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
69
+
30
70
  /** Current platform/arch names as napi-rs names them (no .node suffix). */
31
71
  export function nativeBindingBasename() {
32
72
  const { platform, arch } = process;
@@ -108,40 +148,328 @@ export function runNativeDownloader(cryptoDir) {
108
148
  });
109
149
  }
110
150
 
111
- export function ensureCryptoNative() {
112
- const basename = nativeBindingBasename();
113
- const cryptoDir = resolveCryptoPackageDir();
151
+ // ---------------------------------------------------------------------------
152
+ // Local cache + sha256 manifest (issue #48)
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /** Cache root: $XDG_CACHE_HOME/pi-courier/native-crypto, else ~/.cache/… */
156
+ export function defaultCacheRoot(env = process.env, home = homedir()) {
157
+ const xdg = env.XDG_CACHE_HOME;
158
+ const base = typeof xdg === "string" && xdg.trim() !== "" ? xdg : join(home, ".cache");
159
+ return join(base, CACHE_SUBDIR);
160
+ }
161
+
162
+ /** Manifest ships next to this script (package.json `files` covers scripts/). */
163
+ export function defaultManifestPath() {
164
+ return fileURLToPath(new URL(`./${MANIFEST_FILENAME}`, import.meta.url));
165
+ }
166
+
167
+ /** Cache layout: `<cacheRoot>/<version>/<basename>.node` + `<basename>.sha256`. */
168
+ export function cacheEntryPaths(cacheRoot, version, basename) {
169
+ const dir = join(cacheRoot, version);
170
+ const nodePath = join(dir, `${basename}.node`);
171
+ return { dir, nodePath, shaPath: join(dir, `${basename}.sha256`) };
172
+ }
173
+
174
+ /** sha256 of a file's bytes, lowercase hex. */
175
+ export function sha256FileSync(filePath) {
176
+ return createHash("sha256").update(readFileSync(filePath)).digest("hex");
177
+ }
178
+
179
+ /** The crypto package's own version (cache namespace), or null if unreadable. */
180
+ export function readCryptoPackageVersion(cryptoDir) {
181
+ try {
182
+ const pkg = JSON.parse(readFileSync(`${cryptoDir}/package.json`, "utf8"));
183
+ return typeof pkg.version === "string" && pkg.version !== "" ? pkg.version : null;
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Read the sha256 manifest. Any problem (missing, malformed, wrong shape)
191
+ * degrades to an empty manifest — i.e. "nothing is listed", which makes the
192
+ * flow take the TOFU path instead of ever throwing during install.
193
+ */
194
+ export function readManifest(manifestPath) {
195
+ let parsed;
196
+ try {
197
+ parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
198
+ } catch (err) {
199
+ const message = err instanceof Error ? err.message : String(err);
200
+ console.warn(
201
+ `${LOG_PREFIX} crypto hash manifest unreadable at ${manifestPath} (${message}) — ` +
202
+ `treating every version as unlisted (TOFU).`
203
+ );
204
+ return {};
205
+ }
206
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
207
+ console.warn(
208
+ `${LOG_PREFIX} crypto hash manifest malformed at ${manifestPath} — ` +
209
+ `treating every version as unlisted (TOFU).`
210
+ );
211
+ return {};
212
+ }
213
+ return parsed;
214
+ }
215
+
216
+ function manifestLookup(manifest, key) {
217
+ if (key === null) return { listed: false };
218
+ if (!Object.prototype.hasOwnProperty.call(manifest, key)) return { listed: false };
219
+ const expected = String(manifest[key]).trim().toLowerCase();
220
+ return { listed: true, expected: SHA256_HEX.test(expected) ? expected : null };
221
+ }
222
+
223
+ function manifestNag(key, observedSha256) {
224
+ console.warn(
225
+ `${LOG_PREFIX} crypto binding ${key} is NOT in the sha256 manifest ` +
226
+ `(scripts/${MANIFEST_FILENAME}, observed sha256 ${observedSha256}) — ` +
227
+ `accepted this once (TOFU). ` +
228
+ `Please record that digest so future installs verify against it.`
229
+ );
230
+ }
231
+
232
+ /** tmp path for an atomic write; process-unique and random to survive races. */
233
+ function tmpPathFor(destPath) {
234
+ return `${destPath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
235
+ }
236
+
237
+ /** Write via temp file + atomic rename so an interrupt never leaves half a file. */
238
+ function atomicWrite(destPath, write, mode) {
239
+ const tmp = tmpPathFor(destPath);
240
+ try {
241
+ write(tmp);
242
+ chmodSync(tmp, mode);
243
+ renameSync(tmp, destPath);
244
+ } catch (err) {
245
+ try {
246
+ rmSync(tmp, { force: true });
247
+ } catch {
248
+ // best effort — the tmp residue is inert
249
+ }
250
+ throw err;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Copy a verified .node into the cache: `<version>/<basename>.node` plus a
256
+ * `<basename>.sha256` sidecar with the observed digest. Dir 0700, files 0600,
257
+ * both written atomically (sidecar last: a torn write at worst drops the
258
+ * sidecar, and a cache entry without a sidecar is treated as absent).
259
+ */
260
+ export function writeCacheEntry(cacheRoot, version, basename, sourceNodePath, observedSha256) {
261
+ const { dir, nodePath, shaPath } = cacheEntryPaths(cacheRoot, version, basename);
262
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
263
+ chmodSync(dir, 0o700); // mkdir mode is umask-filtered; pin it explicitly
264
+ atomicWrite(nodePath, (tmp) => copyFileSync(sourceNodePath, tmp), 0o600);
265
+ atomicWrite(shaPath, (tmp) => writeFileSync(tmp, `${observedSha256}\n`), 0o600);
266
+ }
267
+
268
+ /**
269
+ * Evaluate the cache entry for `<version>/<basename>` without using it yet.
270
+ *
271
+ * A hit requires: the .node exists, its sha256 matches the sidecar, AND (when
272
+ * the version is listed in the manifest) the manifest digest too. Anything
273
+ * else is a miss — the cached copy is not used but also not deleted here.
274
+ * An unlisted-but-self-consistent entry TOFU-hits with a warning (≤1 per run:
275
+ * this is called at most once per install).
276
+ */
277
+ export function readCacheEntry(cacheRoot, version, basename, manifest) {
278
+ const { nodePath, shaPath } = cacheEntryPaths(cacheRoot, version, basename);
279
+ if (!existsSync(nodePath)) {
280
+ return { hit: false, reason: "absent" };
281
+ }
282
+ let observed;
283
+ try {
284
+ observed = sha256FileSync(nodePath);
285
+ } catch (err) {
286
+ const message = err instanceof Error ? err.message : String(err);
287
+ console.warn(`${LOG_PREFIX} could not hash cached crypto binding ${nodePath}: ${message}`);
288
+ return { hit: false, reason: "unreadable" };
289
+ }
290
+ let sidecar;
291
+ try {
292
+ sidecar = readFileSync(shaPath, "utf8").trim().toLowerCase();
293
+ } catch {
294
+ return { hit: false, reason: "sidecar-missing" };
295
+ }
296
+ if (!SHA256_HEX.test(sidecar) || sidecar !== observed) {
297
+ return { hit: false, reason: "sidecar-mismatch", observed };
298
+ }
299
+ const key = `${version}/${basename}`;
300
+ const listed = manifestLookup(manifest, key);
301
+ if (listed.listed) {
302
+ if (listed.expected === null || listed.expected !== observed) {
303
+ return { hit: false, reason: "manifest-mismatch", observed };
304
+ }
305
+ return { hit: true, nodePath, sha256: observed, verified: "manifest" };
306
+ }
307
+ manifestNag(key, observed);
308
+ return { hit: true, nodePath, sha256: observed, verified: "tofu" };
309
+ }
310
+
311
+ /** Atomically copy a verified cache entry back into the crypto package dir. */
312
+ export function restoreBindingFromCache(cryptoDir, basename, cacheNodePath) {
313
+ const target = `${cryptoDir}/${basename}.node`;
314
+ atomicWrite(target, (tmp) => copyFileSync(cacheNodePath, tmp), 0o644);
315
+ return target;
316
+ }
317
+
318
+ /**
319
+ * Verify a freshly downloaded binding against the manifest. Pure verdict: the
320
+ * caller decides what to delete. Verdicts:
321
+ * manifest-match digest matches the manifest — accept.
322
+ * manifest-mismatch listed digest differs — caller must DELETE the artifact
323
+ * and degrade E2EE; the binary must never be loaded.
324
+ * manifest-miss version not listed — TOFU accept; caller warns.
325
+ */
326
+ export function verifyDownloadedBinding(nodePath, manifestKey, manifest) {
327
+ const observed = sha256FileSync(nodePath);
328
+ const listed = manifestLookup(manifest, manifestKey);
329
+ if (!listed.listed) {
330
+ return { verdict: "manifest-miss", observed, expected: null };
331
+ }
332
+ if (listed.expected === null || listed.expected !== observed) {
333
+ return { verdict: "manifest-mismatch", observed, expected: listed.expected };
334
+ }
335
+ return { verdict: "manifest-match", observed, expected: listed.expected };
336
+ }
337
+
338
+ /**
339
+ * Orchestration. All inputs are injectable so tests run in temp dirs; the
340
+ * no-argument call keeps the exact pre-cache behaviour contract:
341
+ * `{ok, downloaded, reason?}` with failures warned, never thrown.
342
+ *
343
+ * @param {{
344
+ * cryptoDir?: string | null,
345
+ * basename?: string | null,
346
+ * cacheRoot?: string,
347
+ * manifestPath?: string,
348
+ * runDownloader?: (cryptoDir: string) => void,
349
+ * }} [options]
350
+ */
351
+ export function ensureCryptoNative(options = {}) {
352
+ const {
353
+ cryptoDir = resolveCryptoPackageDir(),
354
+ basename = nativeBindingBasename(),
355
+ cacheRoot = defaultCacheRoot(),
356
+ manifestPath = defaultManifestPath(),
357
+ runDownloader = runNativeDownloader,
358
+ } = options;
114
359
 
115
360
  if (!cryptoDir) {
116
361
  console.warn(
117
- "[pi-courier] crypto package not found — can't verify native binding; continuing (E2EE may be unavailable)."
362
+ `${LOG_PREFIX} crypto package not found — can't verify native binding; continuing (E2EE may be unavailable).`
118
363
  );
119
364
  return { ok: false, reason: "crypto-package-not-found" };
120
365
  }
121
366
 
122
367
  if (!isNativeBindingMissing(cryptoDir, basename)) {
123
368
  // Already present (either downloaded once, or platform unsupported).
369
+ // Deliberately no post-hoc verification of an existing binding.
124
370
  return { ok: true, downloaded: false };
125
371
  }
126
372
 
373
+ const version = readCryptoPackageVersion(cryptoDir);
374
+ const manifest = readManifest(manifestPath);
375
+ const key = version !== null && basename !== null ? `${version}/${basename}` : null;
376
+
377
+ // 1) Warm cache: restore a verified copy without touching the network.
378
+ if (key !== null) {
379
+ let entry;
380
+ try {
381
+ entry = readCacheEntry(cacheRoot, version, basename, manifest);
382
+ } catch (err) {
383
+ const message = err instanceof Error ? err.message : String(err);
384
+ console.warn(`${LOG_PREFIX} cache lookup failed: ${message} — falling back to download.`);
385
+ entry = { hit: false };
386
+ }
387
+ if (entry.hit) {
388
+ try {
389
+ restoreBindingFromCache(cryptoDir, basename, entry.nodePath);
390
+ console.info(
391
+ `${LOG_PREFIX} crypto native binding (${basename}.node) restored from local cache ` +
392
+ `(${key}, sha256 ${entry.sha256}) — no download needed.`
393
+ );
394
+ return { ok: true, downloaded: false, restoredFromCache: true, verified: entry.verified };
395
+ } catch (err) {
396
+ const message = err instanceof Error ? err.message : String(err);
397
+ console.warn(
398
+ `${LOG_PREFIX} could not restore crypto binding from cache: ${message} — falling back to download.`
399
+ );
400
+ }
401
+ }
402
+ }
403
+
404
+ // 2) Cache miss: run the upstream downloader (inherits proxy env).
127
405
  console.info(
128
- `[pi-courier] crypto native binding (${basename}.node) missing — downloading (POSTINSTALL due to npm 11 allow-scripts).`
406
+ `${LOG_PREFIX} crypto native binding (${basename}.node) missing — downloading (POSTINSTALL due to npm 11 allow-scripts).`
129
407
  );
130
408
  try {
131
- runNativeDownloader(cryptoDir);
132
- return { ok: true, downloaded: true };
409
+ runDownloader(cryptoDir);
133
410
  } catch (err) {
134
411
  const message = err instanceof Error ? err.message : String(err);
135
412
  console.warn(
136
- `[pi-courier] could not auto-download crypto native binding: ${message}\n` +
413
+ `${LOG_PREFIX} could not auto-download crypto native binding: ${message}\n` +
137
414
  ` The bridge may still run without E2EE. To install manually:\n` +
138
415
  ` cd ${cryptoDir} && node download-lib.js`
139
416
  );
140
417
  return { ok: false, reason: "download-failed" };
141
418
  }
419
+
420
+ // 3) Verify the downloaded binary before accepting it (or caching it).
421
+ const nodePath = `${cryptoDir}/${basename}.node`;
422
+ let check;
423
+ try {
424
+ check = verifyDownloadedBinding(nodePath, key, manifest);
425
+ } catch (err) {
426
+ const message = err instanceof Error ? err.message : String(err);
427
+ console.warn(
428
+ `${LOG_PREFIX} could not verify downloaded crypto binding (${nodePath}): ${message} — ` +
429
+ `continuing without it (E2EE may be unavailable).`
430
+ );
431
+ return { ok: false, reason: "verify-failed" };
432
+ }
433
+
434
+ if (check.verdict === "manifest-mismatch") {
435
+ // Never load an unverified native binary. Delete and degrade.
436
+ try {
437
+ rmSync(nodePath, { force: true });
438
+ } catch (err) {
439
+ const message = err instanceof Error ? err.message : String(err);
440
+ console.warn(`${LOG_PREFIX} could not delete the unverified binding: ${message}`);
441
+ }
442
+ console.warn(
443
+ `${LOG_PREFIX} downloaded crypto binding ${key} FAILED sha256 verification ` +
444
+ `(expected ${check.expected}, observed ${check.observed}) — file deleted, ` +
445
+ `continuing WITHOUT native E2EE crypto (the bridge still runs, encrypted ` +
446
+ `rooms will be unreadable). Possible tampering or upstream asset change.`
447
+ );
448
+ return { ok: false, reason: "hash-mismatch", verified: "rejected" };
449
+ }
450
+
451
+ if (check.verdict === "manifest-miss" && key !== null) {
452
+ manifestNag(key, check.observed);
453
+ }
454
+
455
+ // 4) Best-effort: stash the verified copy for future installs.
456
+ if (key !== null) {
457
+ try {
458
+ writeCacheEntry(cacheRoot, version, basename, nodePath, check.observed);
459
+ } catch (err) {
460
+ const message = err instanceof Error ? err.message : String(err);
461
+ console.warn(`${LOG_PREFIX} could not cache the crypto binding: ${message}`);
462
+ }
463
+ }
464
+
465
+ return {
466
+ ok: true,
467
+ downloaded: true,
468
+ verified: check.verdict === "manifest-match" ? "manifest" : "tofu",
469
+ };
142
470
  }
143
471
 
144
472
  // Allow running both as a bin and importing the named exports for tests.
145
473
  if (process.argv[1] && process.argv[1].endsWith("ensure-crypto-native.mjs")) {
146
474
  ensureCryptoNative();
147
- }
475
+ }