@polyjose/node 0.0.0-stage → 0.1.1

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/backend.js ADDED
@@ -0,0 +1,378 @@
1
+ /**
2
+ * The napi-rs backend of `@polyjose/node` (ADR 0031 §1-§2, `bindings/js/BACKEND.md`).
3
+ *
4
+ * The generated loader `binding.cjs` (`napi build --platform --js binding.cjs`) already is the
5
+ * backend contract: one function per ADR 0030 §4 operation, camelCase, in the ADR's argument
6
+ * order, rejecting with an `Error` whose `code` is one of `errorCodes()` (ADR 0030 §5). So this
7
+ * module only loads it, checks what it loaded and gives it the facade's `Backend` type; it adds no
8
+ * JOSE logic and renames nothing (ADR 0030 §1: loading and integrity checks are entry idiom).
9
+ *
10
+ * The loader is CommonJS (it picks the platform's `.node` file at run time), loaded here through
11
+ * `createRequire`, so the package stays ESM-only (ADR 0031 §3). Its types come from the generated
12
+ * `binding.d.ts`.
13
+ *
14
+ * **Fail closed (feature 0007 F02; review-code.md SF-4, review-security.md M1).** The generated
15
+ * loader returns the first module that loads, of any kind:
16
+ * - `NAPI_RS_NATIVE_LIBRARY_PATH`, if set;
17
+ * - then `./polyjose.<platform>.node` next to it;
18
+ * - then `require('@polyjose/node-<platform>')`, which walks every ancestor `node_modules` and
19
+ * `NODE_PATH`;
20
+ * - then a WASI chain: `./polyjose.wasi.cjs`, then `@polyjose/node-wasm32-wasi`. The napi-rs CLI
21
+ * 3.10.12 template always emits it; no `napi build` option omits it, and
22
+ * `NAPI_RS_FORCE_WASI` / `NAPI_RS_WASI_FLAVOR` can select it.
23
+ * It stamps a JavaScript module found that way `"native"` too. So a JS file planted in an
24
+ * ancestor directory or on `NODE_PATH` would become the crypto backend and could "verify" any
25
+ * token. polyjose publishes only native addons (ADR 0031 §2: eight targets, no WASI build;
26
+ * `@polyjose/wasm` is a separate package). The generated file is never edited; this module wraps
27
+ * it and accepts nothing else.
28
+ *
29
+ * **Before loading,** nothing runs unless every module the loader could pick is a native addon:
30
+ * 1. Neither WASI variable is set, whatever its value.
31
+ * 2. No WASI candidate resolves.
32
+ * 3. Every native candidate that resolves is a `.node` file: the override path, and for every
33
+ * platform the loader probes, `./polyjose.<platform>.node` and `@polyjose/node-<platform>`.
34
+ * `@polyjose/node-<platform>/package.json`, which the loader also reads, must be a
35
+ * `package.json`.
36
+ * Resolution is the loader's own: `backend.js` sits next to `binding.cjs`, and `require.resolve`
37
+ * uses the same directory, `node_modules` walk, `NODE_PATH` and symlink handling as the loader's
38
+ * `require`, and runs no code. Node loads a `.node` file only as a native addon, never as
39
+ * JavaScript.
40
+ *
41
+ * **The hash pin (feature 0007 F07; bindings security audit N-M1, the JVM's 0008 F05 bar).** Steps
42
+ * 1-6 make sure the package loads a native addon at this version, but `version()` is what the
43
+ * addon itself says, and a native addon is as easy to plant as a JavaScript file. So before the
44
+ * loader runs, this module also *authenticates* the addon:
45
+ * a. `addon-sha256.json`, next to this file, maps each platform polyjose built this package for
46
+ * to its addon's SHA-256 (FIPS 180-4). It is generated, never hand-edited:
47
+ * `scripts/addon-hashes.sh`, run by `scripts/build.sh` for a local build and by the release
48
+ * assembly of the main package for every published platform. No map, a malformed one, or no
49
+ * entry for this host refuses the load: nothing is trusted by default.
50
+ * b. It picks the one file the loader would load on this host (the platform name of
51
+ * `hostPlatform`, the loader's own): `NAPI_RS_NATIVE_LIBRARY_PATH` when set, else
52
+ * `./polyjose.<platform>.node`, else `@polyjose/node-<platform>`, resolved as above. A
53
+ * caller's override is not refused; it is held to the same pin.
54
+ * c. It reads that file once, hashes those bytes with `node:crypto` and refuses on a mismatch,
55
+ * naming the platform, the source and both hashes. There is no fallback to another candidate.
56
+ * d. It writes the same bytes, with `O_EXCL`, into a new directory from `fs.mkdtempSync` under
57
+ * `os.tmpdir()` (mode 0700 on POSIX; on Windows the per-user `%TEMP%` ACL), under a random
58
+ * leaf name (macOS looks a leaf name up in `DYLD_LIBRARY_PATH` before the path), checks the
59
+ * copy's SHA-256 again, and points `NAPI_RS_NATIVE_LIBRARY_PATH` at it for the one
60
+ * synchronous `require("./binding.cjs")`. The loader's override branch loads exactly that
61
+ * path and probes nothing else, so between the check and `dlopen` only this user (who can
62
+ * already change anything they run) could swap the file; whoever can write the installed
63
+ * file or an ancestor `node_modules` can't. The caller's environment is restored right
64
+ * after, and the copy is deleted (a loaded library stays mapped; Windows can't delete a
65
+ * loaded DLL, so there the copy stays in the temporary directory).
66
+ *
67
+ * **After loading,** what the loader returned must be:
68
+ * 4. the exports of exactly one `.node` module in the CommonJS cache, the private copy of step
69
+ * d, shared only with a loader (`binding.cjs`), so no JavaScript module wraps or re-exports
70
+ * it, and a `binding.cjs` that other code required first (with its own addon) is refused;
71
+ * 5. stamped `__napiBindingTarget === "native"`;
72
+ * 6. at this package's version: `version()` must equal `package.json`'s version. That is the
73
+ * workspace version, which every release keeps in step (feature 0007 T04). So an addon from
74
+ * another release is refused, whatever `NAPI_RS_ENFORCE_VERSION_CHECK` says (SF-4 item 3).
75
+ * Every refusal is an `Error` with `code` `ERR_POLYJOSE_ADDON_REFUSED`. This doesn't defend against
76
+ * code already running in the process, which can patch anything. It makes sure the package never
77
+ * selects a substitute.
78
+ *
79
+ * `test/loader.test.ts` pins the loader's load targets and checks `LOADER_PLATFORMS` against them.
80
+ * So a CLI bump that adds a candidate fails there before this module can miss it.
81
+ */
82
+ /** The loader's override variable: a path it `require`s instead of probing (binding.cjs `requireNative`). */
83
+ const OVERRIDE = "NAPI_RS_NATIVE_LIBRARY_PATH";
84
+ /** The addon hash map next to this file (step a): `{ "<platform>": "<SHA-256, 64 lowercase hex>" }`. */
85
+ const PINS = "./addon-sha256.json";
86
+ /** The `code` of every load refusal (step c's mismatch included). */
87
+ const REFUSED = "ERR_POLYJOSE_ADDON_REFUSED";
88
+ /** The loader's WASI switches: `NAPI_RS_FORCE_WASI` (`true`, `error`) and `NAPI_RS_WASI_FLAVOR`. */
89
+ const WASI_VARIABLES = ["NAPI_RS_FORCE_WASI", "NAPI_RS_WASI_FLAVOR"];
90
+ /**
91
+ * Everything the loader's WASI chain resolves before it loads (binding.cjs,
92
+ * `__napiWasiResolveCandidate`): the local loader and the package, whose `package.json` alone
93
+ * already proves it is installed.
94
+ */
95
+ const WASI_CANDIDATES = [
96
+ "./polyjose.wasi.cjs",
97
+ "@polyjose/node-wasm32-wasi",
98
+ "@polyjose/node-wasm32-wasi/package.json",
99
+ ];
100
+ /** The eight platforms polyjose publishes an addon for (ADR 0031 §2): the only keys a map may have. */
101
+ const PUBLISHED_PLATFORMS = [
102
+ "darwin-x64",
103
+ "darwin-arm64",
104
+ "linux-x64-gnu",
105
+ "linux-arm64-gnu",
106
+ "linux-x64-musl",
107
+ "linux-arm64-musl",
108
+ "win32-x64-msvc",
109
+ "win32-arm64-msvc",
110
+ ];
111
+ /**
112
+ * Every platform name the generated loader probes: the eight polyjose publishes (ADR 0031 §2) and
113
+ * the napi-rs template's others, which are never published. `test/loader.test.ts` checks that this
114
+ * is exactly the loader's set.
115
+ */
116
+ export const LOADER_PLATFORMS = [
117
+ "darwin-x64",
118
+ "darwin-arm64",
119
+ "linux-x64-gnu",
120
+ "linux-arm64-gnu",
121
+ "linux-x64-musl",
122
+ "linux-arm64-musl",
123
+ "win32-x64-msvc",
124
+ "win32-arm64-msvc",
125
+ "android-arm64",
126
+ "android-arm-eabi",
127
+ "win32-x64-gnu",
128
+ "win32-ia32-msvc",
129
+ "darwin-universal",
130
+ "freebsd-x64",
131
+ "freebsd-arm64",
132
+ "freebsd-ppc64",
133
+ "linux-arm-musleabihf",
134
+ "linux-arm-gnueabihf",
135
+ "linux-loong64-musl",
136
+ "linux-loong64-gnu",
137
+ "linux-riscv64-musl",
138
+ "linux-riscv64-gnu",
139
+ "linux-ppc64-gnu",
140
+ "linux-s390x-gnu",
141
+ "openharmony-arm64",
142
+ "openharmony-x64",
143
+ "openharmony-arm",
144
+ ];
145
+ /**
146
+ * A load refusal, with `code` `ERR_POLYJOSE_ADDON_REFUSED`. It names a variable, a specifier, a
147
+ * platform, a version or a SHA-256; never a path's contents.
148
+ */
149
+ function refuse(reason) {
150
+ throw Object.assign(new Error(`@polyjose/node: ${reason}`), { code: REFUSED });
151
+ }
152
+ /**
153
+ * Where `id` resolves from the loader's directory: the file, or `null` when it is absent. Only
154
+ * `MODULE_NOT_FOUND` means absent. Any other failure, such as an `exports` restriction or an
155
+ * unreadable directory, is a refusal.
156
+ */
157
+ function resolved(req, id) {
158
+ try {
159
+ return req.resolve(id);
160
+ }
161
+ catch (err) {
162
+ if (err?.code === "MODULE_NOT_FOUND")
163
+ return null;
164
+ return refuse(`${id} cannot be resolved cleanly, so it cannot be checked; refusing to load`);
165
+ }
166
+ }
167
+ /** Steps 1-3 of the module comment: every module the loader could pick is a native addon. */
168
+ function checkCandidates(host, req) {
169
+ for (const name of WASI_VARIABLES) {
170
+ if (host.env[name] !== undefined) {
171
+ refuse(`${name} is set, but polyjose publishes no WASI build; unset it to load the native addon`);
172
+ }
173
+ }
174
+ for (const id of WASI_CANDIDATES) {
175
+ if (resolved(req, id) !== null) {
176
+ refuse(`${id} resolves next to the loader, but polyjose publishes no WASI build; remove it`);
177
+ }
178
+ }
179
+ // The loader tests the override for truthiness, so an empty value means "not set".
180
+ const override = host.env[OVERRIDE];
181
+ if (override !== undefined && override !== "") {
182
+ const file = resolved(req, override);
183
+ if (file === null || !file.endsWith(".node")) {
184
+ refuse("NAPI_RS_NATIVE_LIBRARY_PATH does not name a native .node addon; refusing to load");
185
+ }
186
+ }
187
+ for (const platform of LOADER_PLATFORMS) {
188
+ for (const id of [`./polyjose.${platform}.node`, `@polyjose/node-${platform}`]) {
189
+ const file = resolved(req, id);
190
+ if (file !== null && !file.endsWith(".node")) {
191
+ refuse(`${id} resolves to a file that is not a native .node addon; refusing to load`);
192
+ }
193
+ }
194
+ const manifest = resolved(req, `@polyjose/node-${platform}/package.json`);
195
+ if (manifest !== null && !/[\\/]package\.json$/.test(manifest)) {
196
+ refuse(`@polyjose/node-${platform}/package.json resolves to another file; refusing to load`);
197
+ }
198
+ }
199
+ }
200
+ /**
201
+ * The loader's platform name for this host, as binding.cjs `requireNative` computes it for the
202
+ * eight published platforms; any other host gets `<platform>-<arch>`, which no map lists.
203
+ */
204
+ function hostPlatform(host, fs) {
205
+ const { platform, arch } = host;
206
+ if (arch !== "x64" && arch !== "arm64")
207
+ return `${platform}-${arch}`;
208
+ if (platform === "darwin")
209
+ return `darwin-${arch}`;
210
+ if (platform === "linux")
211
+ return `linux-${arch}-${isMusl(host, fs) ? "musl" : "gnu"}`;
212
+ if (platform === "win32") {
213
+ // The loader sends these Windows x64 hosts to win32-x64-gnu, which polyjose doesn't publish.
214
+ const v = host.config?.variables;
215
+ if (arch === "x64" && (v?.shlib_suffix === "dll.a" || v?.node_target_type === "shared_library"))
216
+ return "win32-x64-gnu";
217
+ return `win32-${arch}-msvc`;
218
+ }
219
+ return `${platform}-${arch}`;
220
+ }
221
+ /**
222
+ * musl or glibc, decided as the loader's `isMusl` decides it, in its order: `/usr/bin/ldd` names
223
+ * musl or not; else the process report (setting `excludeNetwork` first, as the loader does): a
224
+ * glibc runtime version means glibc, a `libc.musl-`/`ld-musl-` shared object means musl. The
225
+ * loader's last resort, `ldd --version` in a child process, is not run: without a report this
226
+ * refuses instead of guessing.
227
+ */
228
+ function isMusl(host, fs) {
229
+ try {
230
+ return fs.readFileSync("/usr/bin/ldd", "utf8").includes("musl");
231
+ }
232
+ catch {
233
+ // Unreadable: ask the process report, as the loader does.
234
+ }
235
+ const report = host.report;
236
+ if (report === undefined || typeof report.getReport !== "function") {
237
+ return refuse("cannot tell glibc from musl (no /usr/bin/ldd, no process report); refusing to load");
238
+ }
239
+ report.excludeNetwork = true;
240
+ const r = report.getReport();
241
+ if (r === null || typeof r !== "object")
242
+ return refuse("cannot tell glibc from musl (empty process report)");
243
+ if (r.header?.glibcVersionRuntime)
244
+ return false;
245
+ const objects = r.sharedObjects;
246
+ const musl = (f) => typeof f === "string" && (f.includes("libc.musl-") || f.includes("ld-musl-"));
247
+ return Array.isArray(objects) && objects.some(musl);
248
+ }
249
+ /** Step a: the package's addon hash map, validated, as a null-prototype record. */
250
+ function readPins(req, fs) {
251
+ const file = resolved(req, PINS);
252
+ if (file === null) {
253
+ return refuse("addon-sha256.json is missing, so no native addon is trusted; this package was not assembled by polyjose's build");
254
+ }
255
+ let parsed;
256
+ try {
257
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
258
+ }
259
+ catch {
260
+ return refuse("addon-sha256.json is not valid JSON; refusing to load");
261
+ }
262
+ const pins = Object.create(null);
263
+ const valid = typeof parsed === "object" &&
264
+ parsed !== null &&
265
+ !Array.isArray(parsed) &&
266
+ Object.entries(parsed).every(([platform, hash]) => {
267
+ if (!PUBLISHED_PLATFORMS.includes(platform) || typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash))
268
+ return false;
269
+ pins[platform] = hash;
270
+ return true;
271
+ });
272
+ if (!valid) {
273
+ return refuse("addon-sha256.json is not a map of published platforms to lowercase SHA-256 hex; refusing to load");
274
+ }
275
+ return pins;
276
+ }
277
+ /** SHA-256 (FIPS 180-4) of `bytes`, lowercase hex. */
278
+ function sha256(crypto, bytes) {
279
+ return crypto.createHash("sha256").update(bytes).digest("hex");
280
+ }
281
+ /** Step b: the one file the loader would load on this host, and how it was named. */
282
+ function pickSource(host, req, platform) {
283
+ const override = host.env[OVERRIDE];
284
+ const overridden = override !== undefined && override !== "";
285
+ const ids = overridden ? [override] : [`./polyjose.${platform}.node`, `@polyjose/node-${platform}`];
286
+ for (const id of ids) {
287
+ const file = resolved(req, id);
288
+ if (file !== null)
289
+ return { file, name: overridden ? OVERRIDE : id };
290
+ }
291
+ return refuse(`no native addon for ${platform} is installed (@polyjose/node-${platform}); reinstall without --omit=optional`);
292
+ }
293
+ /**
294
+ * Steps a-d: loads `binding.cjs` with the override pointing at a private, hash-checked copy of
295
+ * the addon. Returns what it loaded and the copy's path (the CommonJS cache key, step 4).
296
+ */
297
+ function loadPinned(host, req, b) {
298
+ const pins = readPins(req, b.fs);
299
+ const platform = hostPlatform(host, b.fs);
300
+ const expected = pins[platform];
301
+ if (expected === undefined) {
302
+ const listed = Object.keys(pins).sort().join(", ");
303
+ return refuse(`this package has no pinned SHA-256 for ${platform} (addon-sha256.json lists ${listed || "none"}); refusing to load`);
304
+ }
305
+ const source = pickSource(host, req, platform);
306
+ const bytes = b.fs.readFileSync(source.file);
307
+ const actual = sha256(b.crypto, bytes);
308
+ if (actual !== expected) {
309
+ refuse(`${source.name} for ${platform} has SHA-256 ${actual}, but this package pins ${expected}; refusing to load it`);
310
+ }
311
+ // The real path, as the CommonJS cache keys it (macOS's tmpdir is a symlink).
312
+ const dir = b.fs.realpathSync(b.fs.mkdtempSync(b.path.join(b.os.tmpdir(), "polyjose-addon-")));
313
+ try {
314
+ const copy = b.path.join(dir, `polyjose-${b.crypto.randomBytes(16).toString("hex")}.node`);
315
+ b.fs.writeFileSync(copy, bytes, { flag: "wx", mode: 0o700 });
316
+ if (sha256(b.crypto, b.fs.readFileSync(copy)) !== expected) {
317
+ refuse(`the private copy of the addon for ${platform} does not have the pinned SHA-256; refusing to load it`);
318
+ }
319
+ const previous = host.env[OVERRIDE];
320
+ host.env[OVERRIDE] = copy;
321
+ try {
322
+ return { loaded: req("./binding.cjs"), copy };
323
+ }
324
+ finally {
325
+ if (previous === undefined)
326
+ delete host.env[OVERRIDE];
327
+ else
328
+ host.env[OVERRIDE] = previous;
329
+ }
330
+ }
331
+ finally {
332
+ try {
333
+ b.fs.rmSync(dir, { recursive: true, force: true });
334
+ }
335
+ catch {
336
+ // Windows can't delete a DLL while it is loaded; the copy stays in the temporary directory.
337
+ }
338
+ }
339
+ }
340
+ /** Steps 4-6 of the module comment: the loaded binding is this release's native addon. */
341
+ function checkLoaded(req, loaded, copy) {
342
+ if (typeof loaded !== "object" || loaded === null) {
343
+ return refuse("the loader returned no binding object");
344
+ }
345
+ const owners = Object.keys(req.cache).filter((file) => req.cache[file]?.exports === loaded);
346
+ const addons = owners.filter((file) => file.endsWith(".node"));
347
+ // A second copy of this package (another path, same platform package) has its own binding.cjs.
348
+ const others = owners.filter((file) => !file.endsWith(".node") && !/[\\/]binding\.cjs$/.test(file));
349
+ if (addons.length !== 1 || addons[0] !== copy || others.length !== 0) {
350
+ refuse("the loaded binding is not exactly the hash-checked native addon; refusing to load");
351
+ }
352
+ if (loaded.__napiBindingTarget !== "native") {
353
+ refuse('the loaded binding is not the native addon (__napiBindingTarget is not "native")');
354
+ }
355
+ const expected = req("./package.json").version;
356
+ const actual = typeof loaded.version === "function" ? loaded.version() : undefined;
357
+ if (typeof expected !== "string" || actual !== expected) {
358
+ refuse(`the native addon is version ${String(actual)}, but this package is ${String(expected)}; reinstall it`);
359
+ }
360
+ return loaded;
361
+ }
362
+ /** Loads `binding.cjs` only when it can load nothing but the pinned native addon (the module comment). */
363
+ function loadBinding(host, moduleUrl) {
364
+ // backend.js and binding.cjs are siblings, so this resolves exactly as the loader does.
365
+ const req = host.getBuiltinModule("node:module").createRequire(moduleUrl);
366
+ checkCandidates(host, req);
367
+ const builtins = {
368
+ fs: host.getBuiltinModule("node:fs"),
369
+ crypto: host.getBuiltinModule("node:crypto"),
370
+ os: host.getBuiltinModule("node:os"),
371
+ path: host.getBuiltinModule("node:path"),
372
+ };
373
+ const { loaded, copy } = loadPinned(host, req, builtins);
374
+ return checkLoaded(req, loaded, copy);
375
+ }
376
+ const binding = loadBinding(globalThis.process, import.meta.url);
377
+ /** The napi loader, typed as the facade's backend. */
378
+ export const backend = binding;