pgserve 2.2.4 → 2.4.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/src/dashboard.js DELETED
@@ -1,217 +0,0 @@
1
- /**
2
- * Dashboard - Informative CLI startup display
3
- *
4
- * Hybrid approach:
5
- * - Scrolling stages (preserved history)
6
- * - In-place progress updates (only during restore)
7
- * - Non-TTY fallback (works in pipes/CI)
8
- *
9
- * Zero external dependencies - pure Node.js ANSI codes
10
- */
11
-
12
- import { readFileSync } from 'fs';
13
- import { join, dirname } from 'path';
14
- import { fileURLToPath } from 'url';
15
-
16
- // Get package version - BUILD_VERSION is injected at compile time via --define
17
- // Falls back to reading package.json for development mode
18
- let version = '1.0.0';
19
- try {
20
- // Check for build-time injected version first
21
- if (typeof BUILD_VERSION !== 'undefined') {
22
- version = BUILD_VERSION;
23
- } else {
24
- const __dirname = dirname(fileURLToPath(import.meta.url));
25
- const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
26
- version = pkg.version;
27
- }
28
- } catch {
29
- // Fallback version
30
- }
31
-
32
- // ANSI escape codes
33
- const ANSI = {
34
- CLEAR_LINE: '\x1B[2K',
35
- MOVE_UP: (n) => `\x1B[${n}A`,
36
- MOVE_DOWN: (n) => `\x1B[${n}B`,
37
- HIDE_CURSOR: '\x1B[?25l',
38
- SHOW_CURSOR: '\x1B[?25h',
39
- GREEN: '\x1B[32m',
40
- YELLOW: '\x1B[33m',
41
- CYAN: '\x1B[36m',
42
- DIM: '\x1B[2m',
43
- RESET: '\x1B[0m',
44
- BOLD: '\x1B[1m'
45
- };
46
-
47
- /**
48
- * Dashboard class for CLI output
49
- */
50
- export class Dashboard {
51
- constructor(options = {}) {
52
- this.enabled = process.stdout.isTTY && !process.env.NO_COLOR;
53
- this.updateInterval = options.updateInterval || 200; // Throttle updates
54
- this.lastUpdate = 0;
55
- this.progressLines = 0; // Track lines to overwrite
56
- this.restoreStartTime = 0;
57
- this.config = options.config || {};
58
- }
59
-
60
- /**
61
- * Show the startup header
62
- */
63
- showHeader(config = {}) {
64
- const mode = config.memoryMode ? 'In-memory' : 'Persistent';
65
- const port = config.port || 8432;
66
- const host = config.host || '127.0.0.1';
67
- const syncTo = config.syncTo ? ` → ${this._maskUrl(config.syncTo)}` : '';
68
-
69
- console.log('');
70
- console.log(`${ANSI.BOLD}pgserve v${version}${ANSI.RESET} - Embedded PostgreSQL Server`);
71
- console.log(`${ANSI.DIM}MODE: ${mode} | PORT: ${port} | HOST: ${host}${syncTo}${ANSI.RESET}`);
72
- console.log('');
73
- }
74
-
75
- /**
76
- * Log a stage completion
77
- */
78
- stage(name, status = 'done') {
79
- const icon = status === 'done' ? `${ANSI.GREEN}[✓]${ANSI.RESET}` :
80
- status === 'error' ? `${ANSI.YELLOW}[✗]${ANSI.RESET}` :
81
- `${ANSI.DIM}[○]${ANSI.RESET}`;
82
- console.log(`${icon} ${name}`);
83
- }
84
-
85
- /**
86
- * Start restore progress section
87
- */
88
- startRestore(totalDatabases, totalTables = 0, totalBytes = 0) {
89
- this.restoreStartTime = Date.now();
90
- this.totalDatabases = totalDatabases;
91
- this.totalTables = totalTables || totalDatabases * 10; // Estimate
92
- this.totalBytes = totalBytes;
93
-
94
- console.log('');
95
- console.log(`${ANSI.CYAN}Restoring from external PostgreSQL...${ANSI.RESET}`);
96
-
97
- if (this.enabled) {
98
- // Reserve lines for progress
99
- console.log(' Databases: 0/0 [░░░░░░░░░░░░░░░░] 0%');
100
- console.log(' Tables: 0/0 [░░░░░░░░░░░░░░░░] 0%');
101
- console.log(' Speed: 0.0 MB/s | ETA: calculating...');
102
- this.progressLines = 3;
103
- process.stdout.write(ANSI.HIDE_CURSOR);
104
- }
105
- }
106
-
107
- /**
108
- * Update restore progress (in-place)
109
- */
110
- updateRestore(metrics) {
111
- if (!this.enabled) return;
112
-
113
- // Throttle updates
114
- const now = Date.now();
115
- if (now - this.lastUpdate < this.updateInterval) return;
116
- this.lastUpdate = now;
117
-
118
- const {
119
- databasesRestored = 0,
120
- totalDatabases = this.totalDatabases || 1,
121
- tablesRestored = 0,
122
- totalTables = this.totalTables || 1,
123
- bytesTransferred = 0
124
- } = metrics;
125
-
126
- // Calculate throughput and ETA
127
- const elapsed = (now - this.restoreStartTime) / 1000;
128
- const throughputMBps = elapsed > 0 ? (bytesTransferred / 1024 / 1024) / elapsed : 0;
129
- const bytesRemaining = this.totalBytes - bytesTransferred;
130
- const eta = throughputMBps > 0 ? Math.ceil(bytesRemaining / (throughputMBps * 1024 * 1024)) : 0;
131
-
132
- // Format progress
133
- const dbPct = Math.round((databasesRestored / totalDatabases) * 100);
134
- const tablePct = Math.round((tablesRestored / totalTables) * 100);
135
- const dbBar = this._progressBar(databasesRestored, totalDatabases);
136
- const tableBar = this._progressBar(tablesRestored, totalTables);
137
- const etaStr = eta > 0 ? `~${eta}s` : 'finishing...';
138
-
139
- // Move up and overwrite
140
- process.stdout.write(ANSI.MOVE_UP(this.progressLines));
141
-
142
- process.stdout.write(ANSI.CLEAR_LINE);
143
- console.log(` Databases: ${String(databasesRestored).padStart(2)}/${totalDatabases} ${dbBar} ${String(dbPct).padStart(3)}%`);
144
-
145
- process.stdout.write(ANSI.CLEAR_LINE);
146
- console.log(` Tables: ${String(tablesRestored).padStart(3)}/${totalTables} ${tableBar} ${String(tablePct).padStart(3)}%`);
147
-
148
- process.stdout.write(ANSI.CLEAR_LINE);
149
- console.log(` Speed: ${throughputMBps.toFixed(1)} MB/s | ETA: ${etaStr}`);
150
- }
151
-
152
- /**
153
- * Complete restore progress (replace with summary)
154
- */
155
- completeRestore(metrics) {
156
- if (this.enabled && this.progressLines > 0) {
157
- // Move up and clear progress lines
158
- process.stdout.write(ANSI.MOVE_UP(this.progressLines));
159
- for (let i = 0; i < this.progressLines; i++) {
160
- process.stdout.write(ANSI.CLEAR_LINE + '\n');
161
- }
162
- process.stdout.write(ANSI.MOVE_UP(this.progressLines));
163
- process.stdout.write(ANSI.SHOW_CURSOR);
164
- this.progressLines = 0;
165
- }
166
-
167
- const duration = ((metrics.endTime || Date.now()) - this.restoreStartTime) / 1000;
168
- const mb = (metrics.bytesTransferred / 1024 / 1024).toFixed(1);
169
-
170
- console.log(`${ANSI.GREEN}[✓]${ANSI.RESET} Restored ${metrics.databasesRestored} database${metrics.databasesRestored !== 1 ? 's' : ''} (${mb} MB in ${duration.toFixed(1)}s)`);
171
- console.log('');
172
- }
173
-
174
- /**
175
- * Show final ready message
176
- */
177
- showReady(config = {}) {
178
- const port = config.port || 8432;
179
- const host = config.host || '127.0.0.1';
180
-
181
- console.log('');
182
- console.log(`${ANSI.GREEN}${ANSI.BOLD}✨ READY${ANSI.RESET}: postgresql://${host}:${port}/<database>`);
183
- console.log(`${ANSI.DIM}Press Ctrl+C to stop${ANSI.RESET}`);
184
- console.log('');
185
- }
186
-
187
- /**
188
- * Generate progress bar
189
- */
190
- _progressBar(current, total, width = 16) {
191
- const pct = total > 0 ? current / total : 0;
192
- const filled = Math.round(pct * width);
193
- const empty = width - filled;
194
- return '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']';
195
- }
196
-
197
- /**
198
- * Mask sensitive URL parts
199
- */
200
- _maskUrl(url) {
201
- try {
202
- const u = new URL(url);
203
- return `${u.protocol}//${u.host}/${u.pathname.split('/')[1] || ''}`;
204
- } catch {
205
- return url.replace(/:[^:@]+@/, ':***@');
206
- }
207
- }
208
-
209
- /**
210
- * Cleanup (show cursor if hidden)
211
- */
212
- cleanup() {
213
- if (this.enabled) {
214
- process.stdout.write(ANSI.SHOW_CURSOR);
215
- }
216
- }
217
- }
@@ -1,479 +0,0 @@
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. peer cwd lookup → /proc/$pid/cwd on Linux, lsof on macOS
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 { execFileSync } from 'child_process';
33
- import fs from 'fs';
34
- import path from 'path';
35
- import { audit, AUDIT_EVENTS } from './audit.js';
36
-
37
- // ---------------------------------------------------------------------------
38
- // Peer credentials: SO_PEERCRED (Linux) / getpeereid + LOCAL_PEERPID (macOS)
39
- // ---------------------------------------------------------------------------
40
-
41
- let _peerCredImpl = null; // populated by initFingerprintFfi()
42
- let _peerCredOverride = null; // test seam — see _setPeerCredImpl()
43
-
44
- /**
45
- * Read kernel-attested peer credentials from a connected Unix socket.
46
- *
47
- * @param {import('net').Socket | number} socket
48
- * @returns {{pid: number, uid: number, gid: number}}
49
- */
50
- export function getPeerCred(socket) {
51
- if (_peerCredOverride) return _peerCredOverride(socket);
52
- if (!_peerCredImpl) {
53
- throw new Error('getPeerCred: FFI not initialized — call await initFingerprintFfi() first');
54
- }
55
- const fd = extractFd(socket);
56
- if (fd == null || fd < 0) {
57
- throw new Error('getPeerCred: socket has no accessible file descriptor');
58
- }
59
- return _peerCredImpl(fd);
60
- }
61
-
62
- function extractFd(socket) {
63
- if (typeof socket === 'number') return socket;
64
- if (socket?._handle && typeof socket._handle.fd === 'number') return socket._handle.fd;
65
- if (typeof socket?.fd === 'number') return socket.fd;
66
- return null;
67
- }
68
-
69
- /**
70
- * Pre-warm the native FFI handle. Call once at daemon startup; tests call it
71
- * in their before-all hook. Safe to call repeatedly.
72
- *
73
- * Bypassed when a test override is installed via `_setPeerCredImpl`.
74
- *
75
- * @returns {Promise<void>}
76
- */
77
- export async function initFingerprintFfi() {
78
- if (_peerCredOverride) return;
79
- if (_peerCredImpl) return;
80
- if (process.platform !== 'linux' && process.platform !== 'darwin') {
81
- throw new Error(`initFingerprintFfi: unsupported platform "${process.platform}"`);
82
- }
83
- // bun:ffi loaded via dynamic import so plain-Node consumers can still
84
- // import the module surface without crashing on module-eval.
85
- const ffi = await import('bun:ffi');
86
- // glibc on Linux ships as libc.so.6 (versioned); macOS ships libc.dylib.
87
- const libcCandidates = process.platform === 'linux'
88
- ? ['libc.so.6', 'libc.so']
89
- : [`libc.${ffi.suffix}`];
90
- const lib = openFirst(ffi, libcCandidates, process.platform === 'linux'
91
- ? {
92
- getsockopt: {
93
- args: [ffi.FFIType.i32, ffi.FFIType.i32, ffi.FFIType.i32, ffi.FFIType.ptr, ffi.FFIType.ptr],
94
- returns: ffi.FFIType.i32,
95
- },
96
- }
97
- : {
98
- getpeereid: {
99
- args: [ffi.FFIType.i32, ffi.FFIType.ptr, ffi.FFIType.ptr],
100
- returns: ffi.FFIType.i32,
101
- },
102
- getsockopt: {
103
- args: [ffi.FFIType.i32, ffi.FFIType.i32, ffi.FFIType.i32, ffi.FFIType.ptr, ffi.FFIType.ptr],
104
- returns: ffi.FFIType.i32,
105
- },
106
- });
107
- _peerCredImpl = process.platform === 'linux'
108
- ? makeLinuxReader(lib.symbols, ffi.ptr)
109
- : makeDarwinReader(lib.symbols, ffi.ptr);
110
- }
111
-
112
- function openFirst(ffi, candidates, signatures) {
113
- let lastError;
114
- for (const name of candidates) {
115
- try {
116
- return ffi.dlopen(name, signatures);
117
- } catch (err) {
118
- lastError = err;
119
- }
120
- }
121
- throw new Error(
122
- `initFingerprintFfi: dlopen failed for [${candidates.join(', ')}]: ${lastError?.message ?? lastError}`,
123
- );
124
- }
125
-
126
- function makeLinuxReader(symbols, ptr) {
127
- // getsockopt(fd, SOL_SOCKET=1, SO_PEERCRED=17, &ucred, &len)
128
- // ucred = { pid: i32, uid: u32, gid: u32 } — 12 bytes packed.
129
- const SOL_SOCKET = 1;
130
- const SO_PEERCRED = 17;
131
- return (fd) => {
132
- const buf = new ArrayBuffer(12);
133
- const view = new DataView(buf);
134
- const len = new Uint32Array([12]);
135
- const rc = symbols.getsockopt(fd, SOL_SOCKET, SO_PEERCRED, ptr(buf), ptr(len));
136
- if (rc !== 0) {
137
- throw new Error(`getsockopt SO_PEERCRED failed (rc=${rc}, fd=${fd})`);
138
- }
139
- return {
140
- pid: view.getInt32(0, true),
141
- uid: view.getUint32(4, true),
142
- gid: view.getUint32(8, true),
143
- };
144
- };
145
- }
146
-
147
- function makeDarwinReader(symbols, ptr) {
148
- // macOS: getpeereid(fd, &uid, &gid) for credentials (no PID).
149
- // LOCAL_PEERPID via getsockopt(SOL_LOCAL=0, optname=2) supplies the pid
150
- // when the kernel has it; otherwise pid=0 (unknown but tolerated — the
151
- // fingerprint never depends on pid, only the GC liveness probe does).
152
- const SOL_LOCAL = 0;
153
- const LOCAL_PEERPID = 2;
154
- return (fd) => {
155
- const idBuf = new ArrayBuffer(8);
156
- const idView = new DataView(idBuf);
157
- const uidPtr = ptr(idBuf, 0);
158
- const gidPtr = ptr(idBuf, 4);
159
- const rc = symbols.getpeereid(fd, uidPtr, gidPtr);
160
- if (rc !== 0) {
161
- throw new Error(`getpeereid failed (rc=${rc}, fd=${fd})`);
162
- }
163
- const uid = idView.getUint32(0, true);
164
- const gid = idView.getUint32(4, true);
165
- let pid = 0;
166
- try {
167
- const pidBuf = new ArrayBuffer(4);
168
- const pidView = new DataView(pidBuf);
169
- const len = new Uint32Array([4]);
170
- if (symbols.getsockopt(fd, SOL_LOCAL, LOCAL_PEERPID, ptr(pidBuf), ptr(len)) === 0) {
171
- pid = pidView.getInt32(0, true);
172
- }
173
- } catch { /* LOCAL_PEERPID unsupported on this kernel */ }
174
- return { pid, uid, gid };
175
- };
176
- }
177
-
178
- // ---------------------------------------------------------------------------
179
- // Peer process metadata reads
180
- // ---------------------------------------------------------------------------
181
-
182
- /**
183
- * Resolve the cwd of a peer process. Linux uses /proc/$pid/cwd; macOS has no
184
- * /proc, so it shells out to the platform lsof binary and parses the cwd row.
185
- * Returns null if the process disappeared, permissions deny the lookup, or the
186
- * host does not expose a cwd for the peer.
187
- *
188
- * @param {number} pid
189
- * @returns {string | null}
190
- */
191
- export function readProcCwd(pid) {
192
- if (!Number.isInteger(pid) || pid <= 0) return null;
193
- if (process.platform === 'darwin') return readDarwinCwd(pid);
194
- if (process.platform !== 'linux') return null;
195
- try {
196
- return fs.readlinkSync(`/proc/${pid}/cwd`);
197
- } catch {
198
- return null;
199
- }
200
- }
201
-
202
- function readDarwinCwd(pid) {
203
- const lsof = process.env.PGSERVE_LSOF_BIN || '/usr/sbin/lsof';
204
- try {
205
- const output = execFileSync(lsof, ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], {
206
- encoding: 'utf8',
207
- timeout: 1000,
208
- stdio: ['ignore', 'pipe', 'ignore'],
209
- });
210
- return parseDarwinLsofCwd(output);
211
- } catch {
212
- return null;
213
- }
214
- }
215
-
216
- export function parseDarwinLsofCwd(output) {
217
- for (const line of String(output || '').split(/\r?\n/)) {
218
- if (line.startsWith('n') && line.length > 1) return line.slice(1);
219
- }
220
- return null;
221
- }
222
-
223
- /**
224
- * Read the peer's argv via /proc/$pid/cmdline (NUL-separated).
225
- * argv[0] is the exe; argv[1] is typically the script.
226
- *
227
- * @param {number} pid
228
- * @returns {string[]}
229
- */
230
- export function readProcCmdline(pid) {
231
- if (process.platform !== 'linux') return [];
232
- try {
233
- const raw = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
234
- if (!raw) return [];
235
- const trimmed = raw.endsWith('\0') ? raw.slice(0, -1) : raw;
236
- return trimmed.split('\0');
237
- } catch {
238
- return [];
239
- }
240
- }
241
-
242
- // ---------------------------------------------------------------------------
243
- // package.json discovery
244
- // ---------------------------------------------------------------------------
245
-
246
- /**
247
- * Walk upward from `startCwd` to the filesystem root, returning the realpath
248
- * of the nearest ancestor `package.json`. Returns null if none found.
249
- *
250
- * Matches Node's `require.resolve` mental model: nested package.json wins
251
- * (deepest match). Monorepos that want the workspace root to win must opt
252
- * in via `pgserve.fingerprintRoot: "monorepo-root"` (deferred to v2.1).
253
- *
254
- * @param {string} startCwd
255
- * @returns {string | null} — absolute realpath to package.json, or null
256
- */
257
- export function findNearestPackageJson(startCwd) {
258
- if (!startCwd) return null;
259
- let dir;
260
- try {
261
- dir = fs.realpathSync(startCwd);
262
- } catch {
263
- return null;
264
- }
265
- while (true) {
266
- const candidate = path.join(dir, 'package.json');
267
- if (fs.existsSync(candidate)) {
268
- try {
269
- return fs.realpathSync(candidate);
270
- } catch {
271
- return candidate;
272
- }
273
- }
274
- const parent = path.dirname(dir);
275
- if (parent === dir) return null;
276
- dir = parent;
277
- }
278
- }
279
-
280
- /**
281
- * Read the `name` field from a package.json file. Returns null if absent,
282
- * malformed, or non-string.
283
- *
284
- * @param {string} packageJsonPath
285
- * @returns {string | null}
286
- */
287
- export function readPackageName(packageJsonPath) {
288
- try {
289
- const raw = fs.readFileSync(packageJsonPath, 'utf8');
290
- const pkg = JSON.parse(raw);
291
- return typeof pkg?.name === 'string' && pkg.name.length > 0 ? pkg.name : null;
292
- } catch {
293
- return null;
294
- }
295
- }
296
-
297
- /**
298
- * Read the `pgserve.persist` flag from a package.json file. Returns false on
299
- * any error (missing file, malformed JSON, missing field) — the default
300
- * lifecycle is ephemeral; persist must be explicitly opted in.
301
- *
302
- * @param {string} packageJsonPath
303
- * @returns {boolean}
304
- */
305
- export function readPersistFlag(packageJsonPath) {
306
- if (!packageJsonPath) return false;
307
- try {
308
- const raw = fs.readFileSync(packageJsonPath, 'utf8');
309
- const pkg = JSON.parse(raw);
310
- return pkg?.pgserve?.persist === true;
311
- } catch {
312
- return false;
313
- }
314
- }
315
-
316
- // ---------------------------------------------------------------------------
317
- // Fingerprint derivations
318
- // ---------------------------------------------------------------------------
319
-
320
- /**
321
- * `sha256(packageRealpath \0 name \0 uid)[:12]`
322
- *
323
- * NUL separators prevent collision-by-concatenation (e.g. project named
324
- * "abc 1000" can't impersonate uid=1000 / project=abc).
325
- *
326
- * @param {{packageRealpath: string, name: string, uid: number|string}} args
327
- * @returns {string} 12 lowercase hex chars
328
- */
329
- export function derivePackageFingerprint({ packageRealpath, name, uid }) {
330
- if (!packageRealpath) throw new Error('derivePackageFingerprint: packageRealpath required');
331
- if (typeof name !== 'string') throw new Error('derivePackageFingerprint: name must be string');
332
- if (uid === undefined || uid === null) throw new Error('derivePackageFingerprint: uid required');
333
- const input = `${packageRealpath}\0${name}\0${String(uid)}`;
334
- return crypto.createHash('sha256').update(input).digest('hex').slice(0, 12);
335
- }
336
-
337
- /**
338
- * Script fallback: hashes uid + cwd + cmdline[1]. Used when no package.json
339
- * exists above the peer's cwd (e.g. a one-off `bun script.js` outside any
340
- * project root).
341
- *
342
- * @param {{uid: number|string, cwd: string, cmdline1: string}} args
343
- * @returns {string} 12 lowercase hex chars
344
- */
345
- export function deriveScriptFingerprint({ uid, cwd, cmdline1 }) {
346
- if (uid === undefined || uid === null) throw new Error('deriveScriptFingerprint: uid required');
347
- if (!cwd) throw new Error('deriveScriptFingerprint: cwd required');
348
- const script = cmdline1 || '';
349
- const input = `${String(uid)}\0${cwd}\0${script}`;
350
- return crypto.createHash('sha256').update(input).digest('hex').slice(0, 12);
351
- }
352
-
353
- // ---------------------------------------------------------------------------
354
- // Top-level resolver
355
- // ---------------------------------------------------------------------------
356
-
357
- /**
358
- * @typedef {{
359
- * fingerprint: string,
360
- * packageRealpath: string | null,
361
- * name: string | null,
362
- * uid: number,
363
- * pid: number,
364
- * gid: number,
365
- * mode: 'package' | 'script',
366
- * cwd: string | null,
367
- * }} FingerprintInfo
368
- */
369
-
370
- /**
371
- * End-to-end: read peer creds, resolve cwd via /proc, find package.json
372
- * (or fall back to script mode), produce the 12-hex fingerprint.
373
- *
374
- * @param {import('net').Socket | number} socket
375
- * @returns {FingerprintInfo}
376
- */
377
- export function fingerprintForPeer(socket) {
378
- return fingerprintFromCred(getPeerCred(socket));
379
- }
380
-
381
- /**
382
- * Pure-input variant: bypass the FFI step. Used by daemon paths that
383
- * already have peer creds and by tests that don't want to spin up a real
384
- * Unix socket pair.
385
- *
386
- * @param {{pid: number, uid: number, gid: number}} cred
387
- * @param {{cwdOverride?: string, cmdlineOverride?: string[]}} [opts]
388
- * @returns {FingerprintInfo}
389
- */
390
- export function fingerprintFromCred(cred, opts = {}) {
391
- if (!cred || typeof cred.uid !== 'number' || typeof cred.pid !== 'number') {
392
- throw new Error('fingerprintFromCred: cred must have numeric pid+uid');
393
- }
394
- const cwd = opts.cwdOverride !== undefined ? opts.cwdOverride : readProcCwd(cred.pid);
395
- const cmdline = opts.cmdlineOverride !== undefined ? opts.cmdlineOverride : readProcCmdline(cred.pid);
396
-
397
- const pkgPath = cwd ? findNearestPackageJson(cwd) : null;
398
- if (pkgPath) {
399
- const name = readPackageName(pkgPath) ?? '';
400
- const fingerprint = derivePackageFingerprint({
401
- packageRealpath: pkgPath,
402
- name,
403
- uid: cred.uid,
404
- });
405
- return {
406
- fingerprint,
407
- packageRealpath: pkgPath,
408
- name,
409
- uid: cred.uid,
410
- pid: cred.pid,
411
- gid: cred.gid,
412
- mode: 'package',
413
- cwd,
414
- };
415
- }
416
-
417
- const fingerprint = deriveScriptFingerprint({
418
- uid: cred.uid,
419
- cwd: cwd || '',
420
- cmdline1: cmdline[1] || '',
421
- });
422
- return {
423
- fingerprint,
424
- packageRealpath: null,
425
- name: null,
426
- uid: cred.uid,
427
- pid: cred.pid,
428
- gid: cred.gid,
429
- mode: 'script',
430
- cwd,
431
- };
432
- }
433
-
434
- // ---------------------------------------------------------------------------
435
- // Daemon accept hook
436
- // ---------------------------------------------------------------------------
437
-
438
- /**
439
- * Wrap `fingerprintForPeer` with a `connection_routed` audit emit.
440
- * The daemon (Group 2) calls this on every control-socket accept.
441
- *
442
- * @param {import('net').Socket | number} socket
443
- * @param {{cwdOverride?: string, cmdlineOverride?: string[], auditTarget?: 'file'|'syslog'}} [opts]
444
- * @returns {FingerprintInfo}
445
- */
446
- export function handleControlAccept(socket, opts = {}) {
447
- const useOverrides = opts.cwdOverride !== undefined || opts.cmdlineOverride !== undefined;
448
- const info = useOverrides
449
- ? fingerprintFromCred(getPeerCred(socket), opts)
450
- : fingerprintForPeer(socket);
451
- audit(
452
- AUDIT_EVENTS.CONNECTION_ROUTED,
453
- {
454
- fingerprint: info.fingerprint,
455
- mode: info.mode,
456
- peer_pid: info.pid,
457
- peer_uid: info.uid,
458
- package_realpath: info.packageRealpath,
459
- name: info.name,
460
- },
461
- opts.auditTarget ? { target: opts.auditTarget } : {},
462
- );
463
- return info;
464
- }
465
-
466
- // ---------------------------------------------------------------------------
467
- // Test seam
468
- // ---------------------------------------------------------------------------
469
-
470
- /**
471
- * Internal: tests can stub the peer-cred reader to avoid spinning up real
472
- * Unix socket pairs when they only care about the cwd/walk/hash logic.
473
- * Pass `null` to restore the default native reader.
474
- *
475
- * @param {((socket: any) => {pid:number, uid:number, gid:number}) | null} fn
476
- */
477
- export function _setPeerCredImpl(fn) {
478
- _peerCredOverride = fn;
479
- }