pgserve 1.2.0 → 2.0.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.
@@ -0,0 +1,453 @@
1
+ /**
2
+ * pgserve fingerprint — kernel-rooted peer identity.
3
+ *
4
+ * On every accept on the daemon's control socket, the daemon needs to derive
5
+ * a stable, 12-hex fingerprint that identifies the calling project. The chain:
6
+ *
7
+ * 1. SO_PEERCRED (Linux) / getpeereid + LOCAL_PEERPID (macOS)
8
+ * → kernel-attested {pid, uid, gid}
9
+ * 2. /proc/$pid/cwd → peer's current working directory (Linux only)
10
+ * 3. walk upward to the nearest package.json
11
+ * 4. if found: fingerprint = sha256(realpath \0 name \0 uid)[:12] mode='package'
12
+ * else: fingerprint = sha256(uid \0 cwd \0 cmdline[1])[:12] mode='script'
13
+ *
14
+ * Properties:
15
+ * - Identity is kernel-rooted: peer can't lie about uid/pid.
16
+ * - Stable across `cwd` changes inside the same project, across
17
+ * `npm install` (we hash realpath), and across runtime swaps
18
+ * (Bun ↔ Node — neither argv[0] nor exe path enters the input).
19
+ * - Monorepos: nearest-ancestor package.json wins (deepest match), matching
20
+ * the `require.resolve` mental model.
21
+ *
22
+ * Daemon integration (Group 2 wires this in):
23
+ * import { handleControlAccept, initFingerprintFfi } from './fingerprint.js';
24
+ * await initFingerprintFfi(); // once at daemon boot
25
+ * server.on('connection', (socket) => {
26
+ * const info = handleControlAccept(socket); // also emits connection_routed
27
+ * // ... resolve DB by info.fingerprint, etc.
28
+ * });
29
+ */
30
+
31
+ import crypto from 'crypto';
32
+ import fs from 'fs';
33
+ import path from 'path';
34
+ import { audit, AUDIT_EVENTS } from './audit.js';
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Peer credentials: SO_PEERCRED (Linux) / getpeereid + LOCAL_PEERPID (macOS)
38
+ // ---------------------------------------------------------------------------
39
+
40
+ let _peerCredImpl = null; // populated by initFingerprintFfi()
41
+ let _peerCredOverride = null; // test seam — see _setPeerCredImpl()
42
+
43
+ /**
44
+ * Read kernel-attested peer credentials from a connected Unix socket.
45
+ *
46
+ * @param {import('net').Socket | number} socket
47
+ * @returns {{pid: number, uid: number, gid: number}}
48
+ */
49
+ export function getPeerCred(socket) {
50
+ if (_peerCredOverride) return _peerCredOverride(socket);
51
+ if (!_peerCredImpl) {
52
+ throw new Error('getPeerCred: FFI not initialized — call await initFingerprintFfi() first');
53
+ }
54
+ const fd = extractFd(socket);
55
+ if (fd == null || fd < 0) {
56
+ throw new Error('getPeerCred: socket has no accessible file descriptor');
57
+ }
58
+ return _peerCredImpl(fd);
59
+ }
60
+
61
+ function extractFd(socket) {
62
+ if (typeof socket === 'number') return socket;
63
+ if (socket?._handle && typeof socket._handle.fd === 'number') return socket._handle.fd;
64
+ if (typeof socket?.fd === 'number') return socket.fd;
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * Pre-warm the native FFI handle. Call once at daemon startup; tests call it
70
+ * in their before-all hook. Safe to call repeatedly.
71
+ *
72
+ * Bypassed when a test override is installed via `_setPeerCredImpl`.
73
+ *
74
+ * @returns {Promise<void>}
75
+ */
76
+ export async function initFingerprintFfi() {
77
+ if (_peerCredOverride) return;
78
+ if (_peerCredImpl) return;
79
+ if (process.platform !== 'linux' && process.platform !== 'darwin') {
80
+ throw new Error(`initFingerprintFfi: unsupported platform "${process.platform}"`);
81
+ }
82
+ // bun:ffi loaded via dynamic import so plain-Node consumers can still
83
+ // import the module surface without crashing on module-eval.
84
+ const ffi = await import('bun:ffi');
85
+ // glibc on Linux ships as libc.so.6 (versioned); macOS ships libc.dylib.
86
+ const libcCandidates = process.platform === 'linux'
87
+ ? ['libc.so.6', 'libc.so']
88
+ : [`libc.${ffi.suffix}`];
89
+ const lib = openFirst(ffi, libcCandidates, process.platform === 'linux'
90
+ ? {
91
+ getsockopt: {
92
+ args: [ffi.FFIType.i32, ffi.FFIType.i32, ffi.FFIType.i32, ffi.FFIType.ptr, ffi.FFIType.ptr],
93
+ returns: ffi.FFIType.i32,
94
+ },
95
+ }
96
+ : {
97
+ getpeereid: {
98
+ args: [ffi.FFIType.i32, ffi.FFIType.ptr, ffi.FFIType.ptr],
99
+ returns: ffi.FFIType.i32,
100
+ },
101
+ getsockopt: {
102
+ args: [ffi.FFIType.i32, ffi.FFIType.i32, ffi.FFIType.i32, ffi.FFIType.ptr, ffi.FFIType.ptr],
103
+ returns: ffi.FFIType.i32,
104
+ },
105
+ });
106
+ _peerCredImpl = process.platform === 'linux'
107
+ ? makeLinuxReader(lib.symbols, ffi.ptr)
108
+ : makeDarwinReader(lib.symbols, ffi.ptr);
109
+ }
110
+
111
+ function openFirst(ffi, candidates, signatures) {
112
+ let lastError;
113
+ for (const name of candidates) {
114
+ try {
115
+ return ffi.dlopen(name, signatures);
116
+ } catch (err) {
117
+ lastError = err;
118
+ }
119
+ }
120
+ throw new Error(
121
+ `initFingerprintFfi: dlopen failed for [${candidates.join(', ')}]: ${lastError?.message ?? lastError}`,
122
+ );
123
+ }
124
+
125
+ function makeLinuxReader(symbols, ptr) {
126
+ // getsockopt(fd, SOL_SOCKET=1, SO_PEERCRED=17, &ucred, &len)
127
+ // ucred = { pid: i32, uid: u32, gid: u32 } — 12 bytes packed.
128
+ const SOL_SOCKET = 1;
129
+ const SO_PEERCRED = 17;
130
+ return (fd) => {
131
+ const buf = new ArrayBuffer(12);
132
+ const view = new DataView(buf);
133
+ const len = new Uint32Array([12]);
134
+ const rc = symbols.getsockopt(fd, SOL_SOCKET, SO_PEERCRED, ptr(buf), ptr(len));
135
+ if (rc !== 0) {
136
+ throw new Error(`getsockopt SO_PEERCRED failed (rc=${rc}, fd=${fd})`);
137
+ }
138
+ return {
139
+ pid: view.getInt32(0, true),
140
+ uid: view.getUint32(4, true),
141
+ gid: view.getUint32(8, true),
142
+ };
143
+ };
144
+ }
145
+
146
+ function makeDarwinReader(symbols, ptr) {
147
+ // macOS: getpeereid(fd, &uid, &gid) for credentials (no PID).
148
+ // LOCAL_PEERPID via getsockopt(SOL_LOCAL=0, optname=2) supplies the pid
149
+ // when the kernel has it; otherwise pid=0 (unknown but tolerated — the
150
+ // fingerprint never depends on pid, only the GC liveness probe does).
151
+ const SOL_LOCAL = 0;
152
+ const LOCAL_PEERPID = 2;
153
+ return (fd) => {
154
+ const idBuf = new ArrayBuffer(8);
155
+ const idView = new DataView(idBuf);
156
+ const uidPtr = ptr(idBuf, 0);
157
+ const gidPtr = ptr(idBuf, 4);
158
+ const rc = symbols.getpeereid(fd, uidPtr, gidPtr);
159
+ if (rc !== 0) {
160
+ throw new Error(`getpeereid failed (rc=${rc}, fd=${fd})`);
161
+ }
162
+ const uid = idView.getUint32(0, true);
163
+ const gid = idView.getUint32(4, true);
164
+ let pid = 0;
165
+ try {
166
+ const pidBuf = new ArrayBuffer(4);
167
+ const pidView = new DataView(pidBuf);
168
+ const len = new Uint32Array([4]);
169
+ if (symbols.getsockopt(fd, SOL_LOCAL, LOCAL_PEERPID, ptr(pidBuf), ptr(len)) === 0) {
170
+ pid = pidView.getInt32(0, true);
171
+ }
172
+ } catch { /* LOCAL_PEERPID unsupported on this kernel */ }
173
+ return { pid, uid, gid };
174
+ };
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // /proc reads — Linux-only; macOS daemon support is best-effort
179
+ // ---------------------------------------------------------------------------
180
+
181
+ /**
182
+ * Resolve the cwd of a peer process via /proc/$pid/cwd. Linux-only.
183
+ * Returns null if the symlink cannot be read (process gone, EACCES, etc).
184
+ *
185
+ * @param {number} pid
186
+ * @returns {string | null}
187
+ */
188
+ export function readProcCwd(pid) {
189
+ if (process.platform !== 'linux') return null;
190
+ try {
191
+ return fs.readlinkSync(`/proc/${pid}/cwd`);
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Read the peer's argv via /proc/$pid/cmdline (NUL-separated).
199
+ * argv[0] is the exe; argv[1] is typically the script.
200
+ *
201
+ * @param {number} pid
202
+ * @returns {string[]}
203
+ */
204
+ export function readProcCmdline(pid) {
205
+ if (process.platform !== 'linux') return [];
206
+ try {
207
+ const raw = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
208
+ if (!raw) return [];
209
+ const trimmed = raw.endsWith('\0') ? raw.slice(0, -1) : raw;
210
+ return trimmed.split('\0');
211
+ } catch {
212
+ return [];
213
+ }
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // package.json discovery
218
+ // ---------------------------------------------------------------------------
219
+
220
+ /**
221
+ * Walk upward from `startCwd` to the filesystem root, returning the realpath
222
+ * of the nearest ancestor `package.json`. Returns null if none found.
223
+ *
224
+ * Matches Node's `require.resolve` mental model: nested package.json wins
225
+ * (deepest match). Monorepos that want the workspace root to win must opt
226
+ * in via `pgserve.fingerprintRoot: "monorepo-root"` (deferred to v2.1).
227
+ *
228
+ * @param {string} startCwd
229
+ * @returns {string | null} — absolute realpath to package.json, or null
230
+ */
231
+ export function findNearestPackageJson(startCwd) {
232
+ if (!startCwd) return null;
233
+ let dir;
234
+ try {
235
+ dir = fs.realpathSync(startCwd);
236
+ } catch {
237
+ return null;
238
+ }
239
+ while (true) {
240
+ const candidate = path.join(dir, 'package.json');
241
+ if (fs.existsSync(candidate)) {
242
+ try {
243
+ return fs.realpathSync(candidate);
244
+ } catch {
245
+ return candidate;
246
+ }
247
+ }
248
+ const parent = path.dirname(dir);
249
+ if (parent === dir) return null;
250
+ dir = parent;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Read the `name` field from a package.json file. Returns null if absent,
256
+ * malformed, or non-string.
257
+ *
258
+ * @param {string} packageJsonPath
259
+ * @returns {string | null}
260
+ */
261
+ export function readPackageName(packageJsonPath) {
262
+ try {
263
+ const raw = fs.readFileSync(packageJsonPath, 'utf8');
264
+ const pkg = JSON.parse(raw);
265
+ return typeof pkg?.name === 'string' && pkg.name.length > 0 ? pkg.name : null;
266
+ } catch {
267
+ return null;
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Read the `pgserve.persist` flag from a package.json file. Returns false on
273
+ * any error (missing file, malformed JSON, missing field) — the default
274
+ * lifecycle is ephemeral; persist must be explicitly opted in.
275
+ *
276
+ * @param {string} packageJsonPath
277
+ * @returns {boolean}
278
+ */
279
+ export function readPersistFlag(packageJsonPath) {
280
+ if (!packageJsonPath) return false;
281
+ try {
282
+ const raw = fs.readFileSync(packageJsonPath, 'utf8');
283
+ const pkg = JSON.parse(raw);
284
+ return pkg?.pgserve?.persist === true;
285
+ } catch {
286
+ return false;
287
+ }
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Fingerprint derivations
292
+ // ---------------------------------------------------------------------------
293
+
294
+ /**
295
+ * `sha256(packageRealpath \0 name \0 uid)[:12]`
296
+ *
297
+ * NUL separators prevent collision-by-concatenation (e.g. project named
298
+ * "abc 1000" can't impersonate uid=1000 / project=abc).
299
+ *
300
+ * @param {{packageRealpath: string, name: string, uid: number|string}} args
301
+ * @returns {string} 12 lowercase hex chars
302
+ */
303
+ export function derivePackageFingerprint({ packageRealpath, name, uid }) {
304
+ if (!packageRealpath) throw new Error('derivePackageFingerprint: packageRealpath required');
305
+ if (typeof name !== 'string') throw new Error('derivePackageFingerprint: name must be string');
306
+ if (uid === undefined || uid === null) throw new Error('derivePackageFingerprint: uid required');
307
+ const input = `${packageRealpath}\0${name}\0${String(uid)}`;
308
+ return crypto.createHash('sha256').update(input).digest('hex').slice(0, 12);
309
+ }
310
+
311
+ /**
312
+ * Script fallback: hashes uid + cwd + cmdline[1]. Used when no package.json
313
+ * exists above the peer's cwd (e.g. a one-off `bun script.js` outside any
314
+ * project root).
315
+ *
316
+ * @param {{uid: number|string, cwd: string, cmdline1: string}} args
317
+ * @returns {string} 12 lowercase hex chars
318
+ */
319
+ export function deriveScriptFingerprint({ uid, cwd, cmdline1 }) {
320
+ if (uid === undefined || uid === null) throw new Error('deriveScriptFingerprint: uid required');
321
+ if (!cwd) throw new Error('deriveScriptFingerprint: cwd required');
322
+ const script = cmdline1 || '';
323
+ const input = `${String(uid)}\0${cwd}\0${script}`;
324
+ return crypto.createHash('sha256').update(input).digest('hex').slice(0, 12);
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Top-level resolver
329
+ // ---------------------------------------------------------------------------
330
+
331
+ /**
332
+ * @typedef {{
333
+ * fingerprint: string,
334
+ * packageRealpath: string | null,
335
+ * name: string | null,
336
+ * uid: number,
337
+ * pid: number,
338
+ * gid: number,
339
+ * mode: 'package' | 'script',
340
+ * cwd: string | null,
341
+ * }} FingerprintInfo
342
+ */
343
+
344
+ /**
345
+ * End-to-end: read peer creds, resolve cwd via /proc, find package.json
346
+ * (or fall back to script mode), produce the 12-hex fingerprint.
347
+ *
348
+ * @param {import('net').Socket | number} socket
349
+ * @returns {FingerprintInfo}
350
+ */
351
+ export function fingerprintForPeer(socket) {
352
+ return fingerprintFromCred(getPeerCred(socket));
353
+ }
354
+
355
+ /**
356
+ * Pure-input variant: bypass the FFI step. Used by daemon paths that
357
+ * already have peer creds and by tests that don't want to spin up a real
358
+ * Unix socket pair.
359
+ *
360
+ * @param {{pid: number, uid: number, gid: number}} cred
361
+ * @param {{cwdOverride?: string, cmdlineOverride?: string[]}} [opts]
362
+ * @returns {FingerprintInfo}
363
+ */
364
+ export function fingerprintFromCred(cred, opts = {}) {
365
+ if (!cred || typeof cred.uid !== 'number' || typeof cred.pid !== 'number') {
366
+ throw new Error('fingerprintFromCred: cred must have numeric pid+uid');
367
+ }
368
+ const cwd = opts.cwdOverride !== undefined ? opts.cwdOverride : readProcCwd(cred.pid);
369
+ const cmdline = opts.cmdlineOverride !== undefined ? opts.cmdlineOverride : readProcCmdline(cred.pid);
370
+
371
+ const pkgPath = cwd ? findNearestPackageJson(cwd) : null;
372
+ if (pkgPath) {
373
+ const name = readPackageName(pkgPath) ?? '';
374
+ const fingerprint = derivePackageFingerprint({
375
+ packageRealpath: pkgPath,
376
+ name,
377
+ uid: cred.uid,
378
+ });
379
+ return {
380
+ fingerprint,
381
+ packageRealpath: pkgPath,
382
+ name,
383
+ uid: cred.uid,
384
+ pid: cred.pid,
385
+ gid: cred.gid,
386
+ mode: 'package',
387
+ cwd,
388
+ };
389
+ }
390
+
391
+ const fingerprint = deriveScriptFingerprint({
392
+ uid: cred.uid,
393
+ cwd: cwd || '',
394
+ cmdline1: cmdline[1] || '',
395
+ });
396
+ return {
397
+ fingerprint,
398
+ packageRealpath: null,
399
+ name: null,
400
+ uid: cred.uid,
401
+ pid: cred.pid,
402
+ gid: cred.gid,
403
+ mode: 'script',
404
+ cwd,
405
+ };
406
+ }
407
+
408
+ // ---------------------------------------------------------------------------
409
+ // Daemon accept hook
410
+ // ---------------------------------------------------------------------------
411
+
412
+ /**
413
+ * Wrap `fingerprintForPeer` with a `connection_routed` audit emit.
414
+ * The daemon (Group 2) calls this on every control-socket accept.
415
+ *
416
+ * @param {import('net').Socket | number} socket
417
+ * @param {{cwdOverride?: string, cmdlineOverride?: string[], auditTarget?: 'file'|'syslog'}} [opts]
418
+ * @returns {FingerprintInfo}
419
+ */
420
+ export function handleControlAccept(socket, opts = {}) {
421
+ const useOverrides = opts.cwdOverride !== undefined || opts.cmdlineOverride !== undefined;
422
+ const info = useOverrides
423
+ ? fingerprintFromCred(getPeerCred(socket), opts)
424
+ : fingerprintForPeer(socket);
425
+ audit(
426
+ AUDIT_EVENTS.CONNECTION_ROUTED,
427
+ {
428
+ fingerprint: info.fingerprint,
429
+ mode: info.mode,
430
+ peer_pid: info.pid,
431
+ peer_uid: info.uid,
432
+ package_realpath: info.packageRealpath,
433
+ name: info.name,
434
+ },
435
+ opts.auditTarget ? { target: opts.auditTarget } : {},
436
+ );
437
+ return info;
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // Test seam
442
+ // ---------------------------------------------------------------------------
443
+
444
+ /**
445
+ * Internal: tests can stub the peer-cred reader to avoid spinning up real
446
+ * Unix socket pairs when they only care about the cwd/walk/hash logic.
447
+ * Pass `null` to restore the default native reader.
448
+ *
449
+ * @param {((socket: any) => {pid:number, uid:number, gid:number}) | null} fn
450
+ */
451
+ export function _setPeerCredImpl(fn) {
452
+ _peerCredOverride = fn;
453
+ }