@crosshands/runtime 0.1.2

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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/dist/broker/broker.d.ts +53 -0
  3. package/dist/broker/broker.d.ts.map +1 -0
  4. package/dist/broker/broker.js +346 -0
  5. package/dist/broker/broker.js.map +1 -0
  6. package/dist/bundle-lifecycle.d.ts +75 -0
  7. package/dist/bundle-lifecycle.d.ts.map +1 -0
  8. package/dist/bundle-lifecycle.js +247 -0
  9. package/dist/bundle-lifecycle.js.map +1 -0
  10. package/dist/index.d.ts +10 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +10 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/ipc/control-transport.d.ts +59 -0
  15. package/dist/ipc/control-transport.d.ts.map +1 -0
  16. package/dist/ipc/control-transport.js +389 -0
  17. package/dist/ipc/control-transport.js.map +1 -0
  18. package/dist/ipc/endpoint.d.ts +15 -0
  19. package/dist/ipc/endpoint.d.ts.map +1 -0
  20. package/dist/ipc/endpoint.js +16 -0
  21. package/dist/ipc/endpoint.js.map +1 -0
  22. package/dist/ipc/framing.d.ts +4 -0
  23. package/dist/ipc/framing.d.ts.map +1 -0
  24. package/dist/ipc/framing.js +32 -0
  25. package/dist/ipc/framing.js.map +1 -0
  26. package/dist/ipc/unix.d.ts +17 -0
  27. package/dist/ipc/unix.d.ts.map +1 -0
  28. package/dist/ipc/unix.js +106 -0
  29. package/dist/ipc/unix.js.map +1 -0
  30. package/dist/ipc/windows.d.ts +13 -0
  31. package/dist/ipc/windows.d.ts.map +1 -0
  32. package/dist/ipc/windows.js +10 -0
  33. package/dist/ipc/windows.js.map +1 -0
  34. package/dist/policy/policy.d.ts +23 -0
  35. package/dist/policy/policy.d.ts.map +1 -0
  36. package/dist/policy/policy.js +53 -0
  37. package/dist/policy/policy.js.map +1 -0
  38. package/dist/providers/supervisor.d.ts +17 -0
  39. package/dist/providers/supervisor.d.ts.map +1 -0
  40. package/dist/providers/supervisor.js +122 -0
  41. package/dist/providers/supervisor.js.map +1 -0
  42. package/package.json +37 -0
  43. package/src/broker/broker.ts +439 -0
  44. package/src/bundle-lifecycle.ts +385 -0
  45. package/src/index.ts +9 -0
  46. package/src/ipc/control-transport.ts +517 -0
  47. package/src/ipc/endpoint.ts +27 -0
  48. package/src/ipc/framing.ts +31 -0
  49. package/src/ipc/unix.ts +110 -0
  50. package/src/ipc/windows.ts +26 -0
  51. package/src/policy/policy.ts +73 -0
  52. package/src/providers/supervisor.ts +140 -0
@@ -0,0 +1,517 @@
1
+ import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
2
+ import { chmod, open, rename, unlink } from 'node:fs/promises'
3
+ import net, { type Socket } from 'node:net'
4
+
5
+ import {
6
+ createComputerError,
7
+ negotiateVersionHandshake,
8
+ type ContractVersions
9
+ } from '@crosshands/contract'
10
+
11
+ import type { BrokerEndpoint } from './endpoint.js'
12
+ import { DEFAULT_MAX_CONTROL_MESSAGE_BYTES, encodeFrame } from './framing.js'
13
+ import { acquireUnixLease, validateUnixRuntimeDirectory, type UnixLease } from './unix.js'
14
+ import {
15
+ windowsPipeSecurity,
16
+ type WindowsPipeSecurityBackend,
17
+ type WindowsPipePeer
18
+ } from './windows.js'
19
+
20
+ export type LocalControlIdentity = {
21
+ osIdentity: string
22
+ graphicalSessionId: string
23
+ }
24
+
25
+ const CONTROL_RESPONSE_GRACE_MS = 100
26
+
27
+ export type LocalControlHandshake = {
28
+ type: 'handshake'
29
+ requestId: string
30
+ token: string
31
+ versions: ContractVersions
32
+ identity: LocalControlIdentity
33
+ }
34
+
35
+ export interface LocalPeerAuthenticator {
36
+ authenticate(handshake: LocalControlHandshake, socket: Socket): Promise<boolean>
37
+ }
38
+
39
+ export type LocalControlRequest = {
40
+ requestId: string
41
+ payload: unknown
42
+ deadlineAt: number
43
+ }
44
+
45
+ export type LocalControlServerOptions = {
46
+ endpoint: BrokerEndpoint
47
+ runtimeDirectory?: string
48
+ tokenFile?: string
49
+ identity: LocalControlIdentity
50
+ handler: (request: LocalControlRequest) => Promise<unknown>
51
+ authenticator?: LocalPeerAuthenticator
52
+ windowsSecurity?: WindowsPipeSecurityBackend
53
+ maxFrameBytes?: number
54
+ maxFramesPerConnection?: number
55
+ now?: () => number
56
+ }
57
+
58
+ type ControlMessage = Record<string, unknown> & { type: string; requestId?: string }
59
+
60
+ class StreamingFrameDecoder {
61
+ readonly #maxBytes: number
62
+ #buffer = Buffer.alloc(0)
63
+
64
+ constructor(maxBytes: number) {
65
+ this.#maxBytes = maxBytes
66
+ }
67
+
68
+ push(chunk: Buffer): unknown[] {
69
+ this.#buffer = Buffer.concat([this.#buffer, chunk])
70
+ const frames: unknown[] = []
71
+ while (this.#buffer.byteLength >= 4) {
72
+ const length = this.#buffer.readUInt32BE(0)
73
+ if (length > this.#maxBytes) throw new RangeError('Frame exceeds maximum message size')
74
+ if (this.#buffer.byteLength < 4 + length) break
75
+ const payload = this.#buffer.subarray(4, 4 + length)
76
+ this.#buffer = this.#buffer.subarray(4 + length)
77
+ try {
78
+ frames.push(JSON.parse(payload.toString('utf8')) as unknown)
79
+ } catch {
80
+ throw new Error('Malformed JSON frame')
81
+ }
82
+ }
83
+ return frames
84
+ }
85
+ }
86
+
87
+ function sameIdentity(a: LocalControlIdentity, b: LocalControlIdentity): boolean {
88
+ return a.osIdentity === b.osIdentity && a.graphicalSessionId === b.graphicalSessionId
89
+ }
90
+
91
+ function safeTokenEqual(actual: string, expected: string): boolean {
92
+ const a = Buffer.from(actual)
93
+ const b = Buffer.from(expected)
94
+ return a.byteLength === b.byteLength && timingSafeEqual(a, b)
95
+ }
96
+
97
+ async function writeAtomicToken(path: string, token: string): Promise<void> {
98
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`
99
+ const handle = await open(temporary, 'wx', 0o600)
100
+ try {
101
+ await handle.writeFile(`${token}\n`, 'utf8')
102
+ await handle.sync()
103
+ } finally {
104
+ await handle.close()
105
+ }
106
+ await rename(temporary, path)
107
+ await chmod(path, 0o600)
108
+ }
109
+
110
+ function errorMessage(
111
+ requestId: string | undefined,
112
+ code: string,
113
+ message: string,
114
+ metadata: { retry?: boolean; remediation?: string; details?: unknown } = {}
115
+ ): object {
116
+ return { type: 'error', requestId: requestId ?? '', code, message, ...metadata }
117
+ }
118
+
119
+ function errorMetadata(cause: unknown): {
120
+ retry?: boolean
121
+ remediation?: string
122
+ details?: unknown
123
+ } {
124
+ if (cause === null || typeof cause !== 'object') return {}
125
+ const value = cause as { retry?: unknown; remediation?: unknown; details?: unknown }
126
+ return {
127
+ ...(typeof value.retry === 'boolean' ? { retry: value.retry } : {}),
128
+ ...(typeof value.remediation === 'string' ? { remediation: value.remediation } : {}),
129
+ ...(value.details === undefined ? {} : { details: value.details })
130
+ }
131
+ }
132
+
133
+ export class LocalControlServer {
134
+ readonly #options: LocalControlServerOptions
135
+ readonly #maxFrameBytes: number
136
+ readonly #maxFrames: number
137
+ readonly #now: () => number
138
+ readonly #sockets = new Set<Socket>()
139
+ #server: net.Server | undefined
140
+ #lease: UnixLease | undefined
141
+ #token = ''
142
+ #requestCount = 0
143
+
144
+ constructor(options: LocalControlServerOptions) {
145
+ this.#options = options
146
+ this.#maxFrameBytes = options.maxFrameBytes ?? DEFAULT_MAX_CONTROL_MESSAGE_BYTES
147
+ this.#maxFrames = options.maxFramesPerConnection ?? 1024
148
+ this.#now = options.now ?? Date.now
149
+ }
150
+
151
+ get requestCount(): number {
152
+ return this.#requestCount
153
+ }
154
+
155
+ async start(): Promise<void> {
156
+ if (this.#server !== undefined) return
157
+ if (this.#options.endpoint.transport === 'unix') {
158
+ const runtimeDirectory = this.#options.runtimeDirectory
159
+ const tokenFile = this.#options.tokenFile
160
+ const uid = process.getuid?.()
161
+ if (runtimeDirectory === undefined || tokenFile === undefined || uid === undefined) {
162
+ throw new Error(
163
+ 'Unix control transport requires a private runtime directory and token file'
164
+ )
165
+ }
166
+ await validateUnixRuntimeDirectory(runtimeDirectory, { uid, stopAt: runtimeDirectory })
167
+ this.#lease = await acquireUnixLease({
168
+ runtimeDirectory,
169
+ endpoint: this.#options.endpoint.address,
170
+ owner: `${this.#options.identity.osIdentity}:${this.#options.identity.graphicalSessionId}`,
171
+ isProcessAlive: (pid) => {
172
+ try {
173
+ process.kill(pid, 0)
174
+ return true
175
+ } catch {
176
+ return false
177
+ }
178
+ }
179
+ })
180
+ this.#token = randomBytes(32).toString('base64url')
181
+ await writeAtomicToken(tokenFile, this.#token)
182
+ } else {
183
+ const backend = windowsPipeSecurity(this.#options.windowsSecurity)
184
+ backend.createCurrentLogonOnlyDacl()
185
+ }
186
+
187
+ const server = net.createServer((socket) => this.#accept(socket))
188
+ this.#server = server
189
+ try {
190
+ await new Promise<void>((resolve, reject) => {
191
+ server.once('error', reject)
192
+ server.listen(this.#options.endpoint.address, () => {
193
+ server.off('error', reject)
194
+ resolve()
195
+ })
196
+ })
197
+ if (this.#options.endpoint.transport === 'unix') {
198
+ await chmod(this.#options.endpoint.address, 0o600)
199
+ }
200
+ } catch (cause) {
201
+ await this.close()
202
+ throw cause
203
+ }
204
+ }
205
+
206
+ #accept(socket: Socket): void {
207
+ this.#sockets.add(socket)
208
+ const decoder = new StreamingFrameDecoder(this.#maxFrameBytes)
209
+ let authenticated = false
210
+ let frameCount = 0
211
+ let chain = Promise.resolve()
212
+ socket.on('close', () => this.#sockets.delete(socket))
213
+ socket.on('error', () => undefined)
214
+ socket.on('data', (chunk: Buffer) => {
215
+ chain = chain
216
+ .then(async () => {
217
+ for (const raw of decoder.push(chunk)) {
218
+ frameCount += 1
219
+ if (frameCount > this.#maxFrames) throw new Error('Connection frame limit exceeded')
220
+ if (raw === null || typeof raw !== 'object' || !('type' in raw)) {
221
+ throw new Error('Malformed control message')
222
+ }
223
+ const message = raw as ControlMessage
224
+ if (!authenticated) {
225
+ if (message.type !== 'handshake') {
226
+ this.#sendAndClose(
227
+ socket,
228
+ errorMessage(message.requestId, 'handshake_required', 'Handshake is required')
229
+ )
230
+ return
231
+ }
232
+ // oxlint-disable-next-line no-await-in-loop -- frames on one connection are ordered.
233
+ authenticated = await this.#authenticate(message, socket)
234
+ if (!authenticated) return
235
+ this.#send(socket, { type: 'handshake-ok', requestId: message.requestId })
236
+ continue
237
+ }
238
+ if (message.type !== 'request') {
239
+ this.#sendAndClose(
240
+ socket,
241
+ errorMessage(message.requestId, 'invalid_message', 'Expected a request message')
242
+ )
243
+ return
244
+ }
245
+ // oxlint-disable-next-line no-await-in-loop -- responses preserve request frame order.
246
+ await this.#handleRequest(socket, message)
247
+ }
248
+ })
249
+ .catch(() => {
250
+ socket.destroy()
251
+ })
252
+ })
253
+ }
254
+
255
+ async #authenticate(message: ControlMessage, socket: Socket): Promise<boolean> {
256
+ const handshake = message as unknown as LocalControlHandshake
257
+ if (
258
+ typeof handshake.requestId !== 'string' ||
259
+ typeof handshake.token !== 'string' ||
260
+ handshake.identity === null ||
261
+ typeof handshake.identity !== 'object' ||
262
+ !sameIdentity(handshake.identity, this.#options.identity)
263
+ ) {
264
+ this.#sendAndClose(
265
+ socket,
266
+ errorMessage(handshake.requestId, 'peer_rejected', 'Peer identity was rejected')
267
+ )
268
+ return false
269
+ }
270
+ const versions = negotiateVersionHandshake(handshake.versions)
271
+ if (!versions.ok) {
272
+ this.#sendAndClose(
273
+ socket,
274
+ errorMessage(
275
+ handshake.requestId,
276
+ versions.error.code,
277
+ versions.error.message,
278
+ versions.error
279
+ )
280
+ )
281
+ return false
282
+ }
283
+ let accepted: boolean
284
+ if (this.#options.authenticator !== undefined) {
285
+ accepted = await this.#options.authenticator.authenticate(handshake, socket)
286
+ } else if (this.#options.endpoint.transport === 'unix') {
287
+ accepted = safeTokenEqual(handshake.token, this.#token)
288
+ } else {
289
+ const peer: WindowsPipePeer = this.#options.windowsSecurity!.verifyPeer()
290
+ accepted =
291
+ !peer.remote &&
292
+ peer.sid === this.#options.identity.osIdentity &&
293
+ peer.logonSessionId === this.#options.identity.graphicalSessionId
294
+ }
295
+ if (!accepted) {
296
+ this.#sendAndClose(
297
+ socket,
298
+ errorMessage(handshake.requestId, 'peer_rejected', 'Peer authentication failed')
299
+ )
300
+ }
301
+ return accepted
302
+ }
303
+
304
+ async #handleRequest(socket: Socket, message: ControlMessage): Promise<void> {
305
+ if (
306
+ typeof message.requestId !== 'string' ||
307
+ typeof message.deadlineAt !== 'number' ||
308
+ !Number.isFinite(message.deadlineAt)
309
+ ) {
310
+ this.#sendAndClose(
311
+ socket,
312
+ errorMessage(message.requestId, 'invalid_request', 'Request ID and deadline are required')
313
+ )
314
+ return
315
+ }
316
+ if (message.deadlineAt <= this.#now()) {
317
+ this.#send(socket, errorMessage(message.requestId, 'timeout', 'Request deadline elapsed'))
318
+ return
319
+ }
320
+ this.#requestCount += 1
321
+ const request: LocalControlRequest = {
322
+ requestId: message.requestId,
323
+ payload: message.payload,
324
+ deadlineAt: message.deadlineAt
325
+ }
326
+ let timer: NodeJS.Timeout | undefined
327
+ try {
328
+ const timeout = new Promise<never>((_, reject) => {
329
+ timer = setTimeout(
330
+ () => reject(createComputerError('timeout', 'Control request deadline elapsed')),
331
+ (message.deadlineAt as number) - this.#now() + CONTROL_RESPONSE_GRACE_MS
332
+ )
333
+ timer.unref()
334
+ })
335
+ const result = await Promise.race([this.#options.handler(request), timeout])
336
+ this.#send(socket, { type: 'response', requestId: message.requestId, result })
337
+ } catch (cause) {
338
+ const code =
339
+ typeof cause === 'object' && cause !== null && 'code' in cause
340
+ ? String(cause.code)
341
+ : 'request_failed'
342
+ this.#send(
343
+ socket,
344
+ errorMessage(
345
+ message.requestId,
346
+ code,
347
+ cause instanceof Error ? cause.message : 'Request failed',
348
+ errorMetadata(cause)
349
+ )
350
+ )
351
+ } finally {
352
+ if (timer !== undefined) clearTimeout(timer)
353
+ }
354
+ }
355
+
356
+ #send(socket: Socket, message: unknown): void {
357
+ socket.write(encodeFrame(message, this.#maxFrameBytes))
358
+ }
359
+
360
+ #sendAndClose(socket: Socket, message: unknown): void {
361
+ socket.end(encodeFrame(message, this.#maxFrameBytes))
362
+ }
363
+
364
+ async close(): Promise<void> {
365
+ const server = this.#server
366
+ this.#server = undefined
367
+ for (const socket of this.#sockets) socket.destroy()
368
+ this.#sockets.clear()
369
+ if (server !== undefined) {
370
+ await new Promise<void>((resolve) => server.close(() => resolve()))
371
+ }
372
+ if (this.#options.endpoint.transport === 'unix') {
373
+ await unlink(this.#options.endpoint.address).catch(() => undefined)
374
+ if (this.#options.tokenFile !== undefined) {
375
+ await unlink(this.#options.tokenFile).catch(() => undefined)
376
+ }
377
+ }
378
+ await this.#lease?.release()
379
+ this.#lease = undefined
380
+ this.#token = ''
381
+ }
382
+ }
383
+
384
+ export type LocalControlClientOptions = {
385
+ endpoint: BrokerEndpoint
386
+ token: string
387
+ versions: ContractVersions
388
+ identity: LocalControlIdentity
389
+ maxFrameBytes?: number
390
+ }
391
+
392
+ export class LocalControlClient {
393
+ readonly #socket: Socket
394
+ readonly #maxFrameBytes: number
395
+ readonly #pending = new Map<
396
+ string,
397
+ {
398
+ resolve: (value: unknown) => void
399
+ reject: (reason: unknown) => void
400
+ timer: NodeJS.Timeout
401
+ }
402
+ >()
403
+ #sequence = 0
404
+
405
+ private constructor(socket: Socket, maxFrameBytes: number) {
406
+ this.#socket = socket
407
+ this.#maxFrameBytes = maxFrameBytes
408
+ const decoder = new StreamingFrameDecoder(maxFrameBytes)
409
+ socket.on('data', (chunk: Buffer) => {
410
+ try {
411
+ for (const raw of decoder.push(chunk)) this.#receive(raw)
412
+ } catch (cause) {
413
+ this.#failAll(cause)
414
+ socket.destroy()
415
+ }
416
+ })
417
+ socket.on('error', (cause) => this.#failAll(cause))
418
+ socket.on('close', () => this.#failAll(new Error('Control connection closed')))
419
+ }
420
+
421
+ static async connect(options: LocalControlClientOptions): Promise<LocalControlClient> {
422
+ const socket = net.createConnection(options.endpoint.address)
423
+ await new Promise<void>((resolve, reject) => {
424
+ socket.once('connect', resolve)
425
+ socket.once('error', reject)
426
+ })
427
+ const client = new LocalControlClient(
428
+ socket,
429
+ options.maxFrameBytes ?? DEFAULT_MAX_CONTROL_MESSAGE_BYTES
430
+ )
431
+ const requestId = `handshake-${randomUUID()}`
432
+ const response = client.#roundTrip(
433
+ requestId,
434
+ {
435
+ type: 'handshake',
436
+ requestId,
437
+ token: options.token,
438
+ versions: options.versions,
439
+ identity: options.identity
440
+ },
441
+ 30_000
442
+ )
443
+ await response
444
+ return client
445
+ }
446
+
447
+ request(payload: unknown, options: { deadlineMs: number }): Promise<unknown> {
448
+ const requestId = `request-${++this.#sequence}`
449
+ return this.#roundTrip(
450
+ requestId,
451
+ {
452
+ type: 'request',
453
+ requestId,
454
+ deadlineAt: Date.now() + options.deadlineMs,
455
+ payload
456
+ },
457
+ Math.max(0, options.deadlineMs) + CONTROL_RESPONSE_GRACE_MS
458
+ )
459
+ }
460
+
461
+ #roundTrip(requestId: string, message: unknown, timeoutMs: number): Promise<unknown> {
462
+ return new Promise((resolve, reject) => {
463
+ const timer = setTimeout(() => {
464
+ this.#pending.delete(requestId)
465
+ reject(createComputerError('timeout', 'Control request timed out'))
466
+ }, timeoutMs)
467
+ timer.unref()
468
+ this.#pending.set(requestId, { resolve, reject, timer })
469
+ this.#socket.write(encodeFrame(message, this.#maxFrameBytes), (cause) => {
470
+ if (cause !== null && cause !== undefined) {
471
+ this.#pending.delete(requestId)
472
+ clearTimeout(timer)
473
+ reject(cause)
474
+ }
475
+ })
476
+ })
477
+ }
478
+
479
+ #receive(raw: unknown): void {
480
+ if (raw === null || typeof raw !== 'object') return
481
+ const message = raw as Record<string, unknown>
482
+ if (typeof message.requestId !== 'string') return
483
+ const pending = this.#pending.get(message.requestId)
484
+ if (pending === undefined) return
485
+ this.#pending.delete(message.requestId)
486
+ clearTimeout(pending.timer)
487
+ if (message.type === 'error') {
488
+ pending.reject(
489
+ Object.assign(new Error(String(message.message)), {
490
+ code: message.code,
491
+ retry: message.retry,
492
+ remediation: message.remediation,
493
+ ...(message.details === undefined ? {} : { details: message.details })
494
+ })
495
+ )
496
+ } else if (message.type === 'response') {
497
+ pending.resolve(message.result)
498
+ } else if (message.type === 'handshake-ok') {
499
+ pending.resolve(undefined)
500
+ } else {
501
+ pending.reject(new Error('Unexpected control response'))
502
+ }
503
+ }
504
+
505
+ #failAll(cause: unknown): void {
506
+ for (const pending of this.#pending.values()) {
507
+ clearTimeout(pending.timer)
508
+ pending.reject(cause)
509
+ }
510
+ this.#pending.clear()
511
+ }
512
+
513
+ async close(): Promise<void> {
514
+ if (this.#socket.destroyed) return
515
+ await new Promise<void>((resolve) => this.#socket.end(resolve))
516
+ }
517
+ }
@@ -0,0 +1,27 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { join } from 'node:path'
3
+
4
+ export type BrokerEndpoint =
5
+ | { transport: 'unix'; address: string }
6
+ | { transport: 'named-pipe'; address: string }
7
+
8
+ export type BrokerEndpointOptions = {
9
+ platform: NodeJS.Platform
10
+ osIdentity: string
11
+ graphicalSessionId: string
12
+ runtimeDirectory?: string
13
+ }
14
+
15
+ export function brokerEndpoint(options: BrokerEndpointOptions): BrokerEndpoint {
16
+ const key = createHash('sha256')
17
+ .update(`${options.osIdentity}\0${options.graphicalSessionId}`)
18
+ .digest('hex')
19
+ .slice(0, 24)
20
+ if (options.platform === 'win32') {
21
+ return { transport: 'named-pipe', address: `\\\\.\\pipe\\crosshands-${key}` }
22
+ }
23
+ if (options.runtimeDirectory === undefined) {
24
+ throw new Error('A private runtime directory is required for Unix IPC')
25
+ }
26
+ return { transport: 'unix', address: join(options.runtimeDirectory, `${key}.sock`) }
27
+ }
@@ -0,0 +1,31 @@
1
+ export const DEFAULT_MAX_CONTROL_MESSAGE_BYTES = 1_048_576
2
+
3
+ export function encodeFrame(value: unknown, maxBytes = DEFAULT_MAX_CONTROL_MESSAGE_BYTES): Buffer {
4
+ const payload = Buffer.from(JSON.stringify(value), 'utf8')
5
+ if (payload.byteLength > maxBytes) throw new RangeError('Frame exceeds maximum message size')
6
+ const header = Buffer.allocUnsafe(4)
7
+ header.writeUInt32BE(payload.byteLength)
8
+ return Buffer.concat([header, payload])
9
+ }
10
+
11
+ export function decodeFrames(
12
+ buffer: Buffer,
13
+ maxBytes = DEFAULT_MAX_CONTROL_MESSAGE_BYTES
14
+ ): unknown[] {
15
+ const frames: unknown[] = []
16
+ let offset = 0
17
+ while (offset < buffer.byteLength) {
18
+ if (buffer.byteLength - offset < 4) throw new Error('Malformed incomplete frame header')
19
+ const length = buffer.readUInt32BE(offset)
20
+ if (length > maxBytes) throw new RangeError('Frame exceeds maximum message size')
21
+ const end = offset + 4 + length
22
+ if (end > buffer.byteLength) throw new Error('Malformed incomplete frame payload')
23
+ try {
24
+ frames.push(JSON.parse(buffer.subarray(offset + 4, end).toString('utf8')) as unknown)
25
+ } catch {
26
+ throw new Error('Malformed JSON frame')
27
+ }
28
+ offset = end
29
+ }
30
+ return frames
31
+ }
@@ -0,0 +1,110 @@
1
+ import { open, lstat, readFile, unlink } from 'node:fs/promises'
2
+ import { dirname, resolve } from 'node:path'
3
+
4
+ export type UnixRuntimeValidationOptions = {
5
+ uid: number
6
+ stopAt?: string
7
+ }
8
+
9
+ export async function validateUnixRuntimeDirectory(
10
+ runtimeDirectory: string,
11
+ options: UnixRuntimeValidationOptions
12
+ ): Promise<void> {
13
+ const stopAt = resolve(options.stopAt ?? runtimeDirectory)
14
+ let current = resolve(runtimeDirectory)
15
+ while (true) {
16
+ // oxlint-disable-next-line no-await-in-loop -- path components must be checked in order.
17
+ const stat = await lstat(current)
18
+ if (stat.isSymbolicLink()) throw new Error('Unix runtime path contains a symbolic link')
19
+ if (!stat.isDirectory()) throw new Error('Unix runtime path is not a directory')
20
+ if (stat.uid !== options.uid) throw new Error('Unix runtime directory has unsafe ownership')
21
+ if ((stat.mode & 0o077) !== 0) throw new Error('Unix runtime directory has unsafe mode')
22
+ if (current === stopAt) break
23
+ const parent = dirname(current)
24
+ if (parent === current || !current.startsWith(`${stopAt}/`)) {
25
+ throw new Error('Unix runtime directory escaped its validated root')
26
+ }
27
+ current = parent
28
+ }
29
+ }
30
+
31
+ type UnixLeaseOptions = {
32
+ runtimeDirectory: string
33
+ endpoint: string
34
+ owner: string
35
+ isProcessAlive: (pid: number) => boolean
36
+ }
37
+
38
+ export type UnixLease = { release(): Promise<void> }
39
+
40
+ async function rejectPrecreatedEndpoint(endpoint: string): Promise<void> {
41
+ try {
42
+ await lstat(endpoint)
43
+ throw new Error('Broker endpoint already exists or was precreated')
44
+ } catch (cause) {
45
+ if (cause instanceof Error && 'code' in cause && cause.code === 'ENOENT') return
46
+ throw cause
47
+ }
48
+ }
49
+
50
+ async function removeVerifiedStaleEndpoint(endpoint: string, uid: number): Promise<void> {
51
+ let info
52
+ try {
53
+ info = await lstat(endpoint)
54
+ } catch (cause) {
55
+ if (cause instanceof Error && 'code' in cause && cause.code === 'ENOENT') return
56
+ throw cause
57
+ }
58
+ if (!info.isSocket() || info.uid !== uid || (info.mode & 0o077) !== 0) {
59
+ throw new Error('Stale broker endpoint is unsafe to remove')
60
+ }
61
+ await unlink(endpoint)
62
+ }
63
+
64
+ export async function acquireUnixLease(options: UnixLeaseOptions): Promise<UnixLease> {
65
+ const uid = process.getuid?.()
66
+ if (uid === undefined) throw new Error('Unix lease is unsupported on this platform')
67
+ await validateUnixRuntimeDirectory(options.runtimeDirectory, {
68
+ uid,
69
+ stopAt: options.runtimeDirectory
70
+ })
71
+ const leasePath = `${options.endpoint}.lease`
72
+
73
+ let handle
74
+ try {
75
+ handle = await open(leasePath, 'wx', 0o600)
76
+ try {
77
+ await rejectPrecreatedEndpoint(options.endpoint)
78
+ } catch (cause) {
79
+ await handle.close()
80
+ await unlink(leasePath)
81
+ throw cause
82
+ }
83
+ } catch (cause) {
84
+ if (!(cause instanceof Error && 'code' in cause && cause.code === 'EEXIST')) throw cause
85
+ let stale = false
86
+ try {
87
+ const existing = JSON.parse(await readFile(leasePath, 'utf8')) as { pid?: unknown }
88
+ stale = typeof existing.pid === 'number' && !options.isProcessAlive(existing.pid)
89
+ } catch (parseCause) {
90
+ throw new Error('Existing broker lease is unverifiable', { cause: parseCause })
91
+ }
92
+ if (!stale) throw new Error('A compatible broker lease is already active', { cause })
93
+ await removeVerifiedStaleEndpoint(options.endpoint, uid)
94
+ await unlink(leasePath)
95
+ handle = await open(leasePath, 'wx', 0o600)
96
+ }
97
+ await handle.writeFile(JSON.stringify({ pid: process.pid, owner: options.owner }), 'utf8')
98
+ await handle.sync()
99
+ await handle.close()
100
+ let released = false
101
+ return {
102
+ async release() {
103
+ if (released) return
104
+ released = true
105
+ await unlink(leasePath).catch((cause: unknown) => {
106
+ if (!(cause instanceof Error && 'code' in cause && cause.code === 'ENOENT')) throw cause
107
+ })
108
+ }
109
+ }
110
+ }