@push.rocks/smartrust 2.1.0 → 2.3.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.
@@ -8,13 +8,18 @@ import type {
8
8
 
9
9
  const DEFAULT_DISCONNECT_GRACE_PERIOD_MS = 5000;
10
10
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
11
+ const SENSITIVE_FRAME_HEADER_SIZE = 16;
12
+ const SENSITIVE_FRAME_MAGIC = 'SMRS';
11
13
 
12
14
  export interface IStdioTransportOptions {
13
15
  binaryPath: string;
14
16
  cliArgs: string[];
15
17
  env?: Record<string, string>;
16
18
  inheritedFileDescriptors?: readonly number[];
19
+ sensitiveByteChannel?: { maxPayloadSize: number };
17
20
  maxPayloadSize: number;
21
+ /** Default for every cleanup path, including automatic stdin failure. */
22
+ terminationGracePeriodMs?: number;
18
23
  logger: IRustBridgeLogger;
19
24
  }
20
25
 
@@ -32,6 +37,9 @@ interface IStdioTransportGeneration {
32
37
  sigtermAttempted: boolean;
33
38
  sigkillAttempted: boolean;
34
39
  sigtermError: Error | null;
40
+ sensitiveWriter: plugins.stream.Writable | null;
41
+ sensitiveUsed: boolean;
42
+ sensitiveError: Error | null;
35
43
  }
36
44
 
37
45
  /**
@@ -47,7 +55,21 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
47
55
 
48
56
  constructor(options: IStdioTransportOptions) {
49
57
  super();
50
- this.options = options;
58
+ if (options.terminationGracePeriodMs !== undefined
59
+ && (!Number.isSafeInteger(options.terminationGracePeriodMs) || options.terminationGracePeriodMs < 0)) {
60
+ throw new RangeError('StdioTransport terminationGracePeriodMs must be a non-negative safe integer');
61
+ }
62
+ if (options.sensitiveByteChannel
63
+ && (!Number.isSafeInteger(options.sensitiveByteChannel.maxPayloadSize)
64
+ || options.sensitiveByteChannel.maxPayloadSize <= 0)) {
65
+ throw new RangeError('StdioTransport sensitiveByteChannel maxPayloadSize must be a positive safe integer');
66
+ }
67
+ this.options = {
68
+ ...options,
69
+ sensitiveByteChannel: options.sensitiveByteChannel
70
+ ? { ...options.sensitiveByteChannel }
71
+ : undefined,
72
+ };
51
73
  this.lineScanner = new LineScanner(options.maxPayloadSize, options.logger);
52
74
  }
53
75
 
@@ -55,6 +77,12 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
55
77
  return this._connected;
56
78
  }
57
79
 
80
+ public get sensitiveFileDescriptor(): number | null {
81
+ return this.options.sensitiveByteChannel
82
+ ? 3 + (this.options.inheritedFileDescriptors?.length ?? 0)
83
+ : null;
84
+ }
85
+
58
86
  /**
59
87
  * Spawn the child process. Resolves when the process is running (not necessarily ready).
60
88
  */
@@ -76,6 +104,7 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
76
104
  'pipe',
77
105
  'pipe',
78
106
  ...(this.options.inheritedFileDescriptors ?? []),
107
+ ...(this.options.sensitiveByteChannel ? ['pipe' as const] : []),
79
108
  ];
80
109
  childProcess = plugins.childProcess.spawn(
81
110
  this.options.binaryPath,
@@ -90,6 +119,12 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
90
119
  const closePromise = new Promise<void>((resolve) => {
91
120
  resolveClose = resolve;
92
121
  });
122
+ const sensitiveEndpoint = this.options.sensitiveByteChannel
123
+ ? childProcess.stdio[this.sensitiveFileDescriptor!]
124
+ : null;
125
+ const sensitiveWriter = sensitiveEndpoint instanceof plugins.stream.Writable
126
+ ? sensitiveEndpoint
127
+ : null;
93
128
  const generation: IStdioTransportGeneration = {
94
129
  childProcess,
95
130
  stderrRemainder: '',
@@ -104,6 +139,9 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
104
139
  sigtermAttempted: false,
105
140
  sigkillAttempted: false,
106
141
  sigtermError: null,
142
+ sensitiveWriter,
143
+ sensitiveUsed: false,
144
+ sensitiveError: null,
107
145
  };
108
146
 
109
147
  this.generation = generation;
@@ -145,6 +183,23 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
145
183
  });
146
184
  });
147
185
 
186
+ generation.sensitiveWriter?.on('error', (error: Error) => {
187
+ if (generation.sensitiveError) return;
188
+ generation.sensitiveError = error;
189
+ if (this.generation !== generation || generation.closed) return;
190
+ this._connected = false;
191
+ queueMicrotask(() => {
192
+ if (this.generation !== generation || generation.closed) return;
193
+ void this.disconnectAndWait().catch((disconnectError: unknown) => {
194
+ this.options.logger.log('error', 'Failed to clean up child after sensitive pipe error: '
195
+ + this.getErrorMessage(disconnectError));
196
+ });
197
+ if (this.listenerCount('error') > 0) this.emit('error', error);
198
+ else if (generation.disconnectPromise) this.options.logger.log('error', 'Sensitive pipe error during disconnect: ' + error.message);
199
+ else this.emit('error', error);
200
+ });
201
+ });
202
+
148
203
  // Handle stderr with cross-chunk buffering.
149
204
  childProcess.stderr?.on('data', (data: Buffer) => {
150
205
  if (this.generation !== generation || generation.closed) {
@@ -192,6 +247,7 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
192
247
  this.destroyOwnedStdioEndpoint(generation, 'stdin');
193
248
  this.destroyOwnedStdioEndpoint(generation, 'stdout');
194
249
  this.destroyOwnedStdioEndpoint(generation, 'stderr');
250
+ this.destroySensitiveEndpoint(generation);
195
251
  });
196
252
  });
197
253
 
@@ -279,6 +335,49 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
279
335
  });
280
336
  }
281
337
 
338
+ /**
339
+ * Write the single sensitive frame owned by this child generation. The frame
340
+ * header and request id are non-secret; `data` is written without converting
341
+ * it to a string or copying its backing ArrayBuffer.
342
+ */
343
+ public async writeSensitiveFrame(
344
+ requestId: string,
345
+ data: Uint8Array<ArrayBuffer>,
346
+ ): Promise<void> {
347
+ if (!(data instanceof Uint8Array) || !(data.buffer instanceof ArrayBuffer)) {
348
+ throw new TypeError('sensitive bytes must use an ArrayBuffer-backed Uint8Array');
349
+ }
350
+ const generation = this.generation;
351
+ if (!generation || generation.closed || generation.childProcess !== this.childProcess
352
+ || !this._connected || !generation.sensitiveWriter || generation.sensitiveWriter.destroyed) {
353
+ throw new Error('sensitive byte pipe not available');
354
+ }
355
+ if (generation.sensitiveUsed) throw new Error('sensitive byte pipe was already used');
356
+ generation.sensitiveUsed = true;
357
+ const maxPayloadSize = this.options.sensitiveByteChannel?.maxPayloadSize;
358
+ if (maxPayloadSize === undefined || data.byteLength > maxPayloadSize) {
359
+ throw new Error('sensitive byte payload exceeds its configured limit');
360
+ }
361
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(requestId)) throw new Error('invalid sensitive request id');
362
+
363
+ const requestIdBytes = Buffer.from(requestId, 'ascii');
364
+ const header = Buffer.alloc(SENSITIVE_FRAME_HEADER_SIZE + requestIdBytes.byteLength);
365
+ header.write(SENSITIVE_FRAME_MAGIC, 0, 4, 'ascii');
366
+ header.writeUInt8(1, 4);
367
+ header.writeUInt8(0, 5);
368
+ header.writeUInt16BE(requestIdBytes.byteLength, 6);
369
+ header.writeBigUInt64BE(BigInt(data.byteLength), 8);
370
+ requestIdBytes.copy(header, SENSITIVE_FRAME_HEADER_SIZE);
371
+ const payload = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
372
+ try {
373
+ await this.writeSensitiveChunk(generation, header, false);
374
+ await this.writeSensitiveChunk(generation, payload, true);
375
+ } catch (error: unknown) {
376
+ this.destroySensitiveEndpoint(generation);
377
+ throw error;
378
+ }
379
+ }
380
+
282
381
  /**
283
382
  * Kill the child process without waiting for confirmation.
284
383
  */
@@ -305,7 +404,7 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
305
404
  public disconnectAndWait(
306
405
  options: IRustTransportDisconnectOptions = {},
307
406
  ): Promise<void> {
308
- const gracePeriodMs = options.gracePeriodMs ?? DEFAULT_DISCONNECT_GRACE_PERIOD_MS;
407
+ const gracePeriodMs = options.gracePeriodMs ?? this.options.terminationGracePeriodMs ?? DEFAULT_DISCONNECT_GRACE_PERIOD_MS;
309
408
  if (!Number.isSafeInteger(gracePeriodMs) || gracePeriodMs < 0) {
310
409
  throw new RangeError(
311
410
  'StdioTransport gracePeriodMs must be a non-negative safe integer',
@@ -332,6 +431,7 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
332
431
  generation.disconnectPromise = disconnectPromise;
333
432
  generation.rejectDisconnect = rejectDisconnect;
334
433
  this._connected = false;
434
+ this.destroySensitiveEndpoint(generation);
335
435
  void disconnectPromise.then(
336
436
  () => {
337
437
  if (generation.disconnectPromise === disconnectPromise) {
@@ -358,6 +458,7 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
358
458
  this.destroyOwnedStdioEndpoint(generation, 'stdin');
359
459
  this.destroyOwnedStdioEndpoint(generation, 'stdout');
360
460
  this.destroyOwnedStdioEndpoint(generation, 'stderr');
461
+ this.destroySensitiveEndpoint(generation);
361
462
  return disconnectPromise;
362
463
  }
363
464
 
@@ -480,6 +581,55 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
480
581
  }
481
582
  }
482
583
 
584
+ private writeSensitiveChunk(
585
+ generation: IStdioTransportGeneration,
586
+ data: Uint8Array,
587
+ end: boolean,
588
+ ): Promise<void> {
589
+ return new Promise<void>((resolve, reject) => {
590
+ const writer = generation.sensitiveWriter;
591
+ if (!writer || writer.destroyed || generation.closed || this.generation !== generation) {
592
+ reject(new Error('sensitive byte pipe not available'));
593
+ return;
594
+ }
595
+ let settled = false;
596
+ const cleanup = (): void => {
597
+ writer.off('error', onError);
598
+ writer.off('close', onClose);
599
+ };
600
+ const settle = (error?: Error | null): void => {
601
+ if (settled) return;
602
+ settled = true;
603
+ cleanup();
604
+ if (error) reject(error);
605
+ else resolve();
606
+ };
607
+ const onError = (error: Error): void => settle(error);
608
+ const onClose = (): void => settle(new Error('sensitive byte pipe closed before write completion'));
609
+ writer.once('error', onError);
610
+ writer.once('close', onClose);
611
+ try {
612
+ if (end) writer.end(data, () => settle());
613
+ else writer.write(data, error => settle(error));
614
+ } catch (error: unknown) {
615
+ settle(this.toError(error, 'Failed to write sensitive byte pipe'));
616
+ }
617
+ });
618
+ }
619
+
620
+ private destroySensitiveEndpoint(generation: IStdioTransportGeneration): void {
621
+ const endpoint = generation.sensitiveWriter;
622
+ if (!endpoint || endpoint.destroyed) return;
623
+ try {
624
+ endpoint.destroy();
625
+ } catch (error: unknown) {
626
+ const endpointError = this.toError(error, 'Failed to close sensitive byte pipe');
627
+ generation.rejectDisconnect?.(endpointError);
628
+ this.options.logger.log('error', endpointError.message);
629
+ if (this.listenerCount('error') > 0) this.emit('error', endpointError);
630
+ }
631
+ }
632
+
483
633
  private toError(error: unknown, prefix: string): Error {
484
634
  return new Error(prefix + ': ' + this.getErrorMessage(error));
485
635
  }
@@ -19,7 +19,8 @@ export interface IRustBridgeRequestOptions {
19
19
  export interface IRustBridgeTerminateOptions {
20
20
  /**
21
21
  * Time to allow an owned stdio child to exit after SIGTERM before SIGKILL.
22
- * Zero requests immediate SIGKILL. Defaults to 5000 milliseconds.
22
+ * Zero requests immediate SIGKILL. Defaults to the bridge's
23
+ * terminationGracePeriodMs, or 5000 milliseconds when not configured.
23
24
  */
24
25
  gracePeriodMs?: number;
25
26
  }
@@ -60,10 +61,27 @@ export interface IRustBridgeOptions extends IBinaryLocatorOptions {
60
61
  * closes the caller's source descriptors.
61
62
  */
62
63
  inheritedFileDescriptors?: readonly number[];
64
+ /**
65
+ * Opt in to one owned, one-shot binary pipe for a sensitive request in each
66
+ * spawned stdio generation. The pipe is appended after caller-owned inherited
67
+ * descriptors, so their child descriptor numbers remain unchanged.
68
+ */
69
+ sensitiveByteChannel?: {
70
+ /** Maximum raw sensitive payload size accepted by the bridge. */
71
+ maxPayloadSize: number;
72
+ };
63
73
  /** Timeout for individual requests in ms (default: 30000) */
64
74
  requestTimeoutMs?: number;
65
75
  /** Timeout for the ready event during spawn in ms (default: 10000) */
66
76
  readyTimeoutMs?: number;
77
+ /**
78
+ * Default stdio SIGTERM-to-SIGKILL grace period in milliseconds, including
79
+ * automatic startup and stdin-failure cleanup (default: 5000).
80
+ * Must be a non-negative safe integer. Explicit terminate() options override
81
+ * it only before cleanup starts; concurrent cleanup keeps its first choice.
82
+ * Socket termination never signals the remote daemon.
83
+ */
84
+ terminationGracePeriodMs?: number;
67
85
  /** Additional environment variables for the child process */
68
86
  env?: Record<string, string>;
69
87
  /** Name of the ready event emitted by the Rust binary (default: 'ready') */
@@ -5,6 +5,20 @@ export interface IManagementRequest {
5
5
  id: string;
6
6
  method: string;
7
7
  params: Record<string, any>;
8
+ /** Value-free metadata for an associated one-shot binary frame. */
9
+ sensitive?: IManagementSensitivePayload;
10
+ }
11
+
12
+ export interface IManagementSensitivePayload {
13
+ protocol: 'smartrust-sensitive-v1';
14
+ fileDescriptor: number;
15
+ byteLength: number;
16
+ }
17
+
18
+ export interface IManagementSensitiveAck {
19
+ protocol: 'smartrust-sensitive-v1';
20
+ consumed: true;
21
+ byteLength: number;
8
22
  }
9
23
 
10
24
  /**
@@ -16,6 +30,8 @@ export interface IManagementResponse {
16
30
  result?: any;
17
31
  error?: string;
18
32
  errorCode?: string;
33
+ /** Required after the receiver consumed the exact associated binary frame. */
34
+ sensitiveAck?: IManagementSensitiveAck;
19
35
  }
20
36
 
21
37
  /**
@@ -35,6 +35,12 @@ export interface IRustTransport extends plugins.events.EventEmitter {
35
35
  /** Write a string to the transport. Handles backpressure. */
36
36
  write(data: string): Promise<void>;
37
37
 
38
+ /** Child descriptor for the optional one-shot sensitive byte pipe. */
39
+ readonly sensitiveFileDescriptor?: number | null;
40
+
41
+ /** Write and close one correlated binary frame. Available only in stdio mode. */
42
+ writeSensitiveFrame?(requestId: string, data: Uint8Array<ArrayBuffer>): Promise<void>;
43
+
38
44
  /** Disconnect the transport. For stdio: kills the process. For socket: closes the connection. */
39
45
  disconnect(): void;
40
46
 
package/ts/plugins.ts CHANGED
@@ -5,8 +5,9 @@ import * as childProcess from 'child_process';
5
5
  import * as events from 'events';
6
6
  import * as url from 'url';
7
7
  import * as net from 'net';
8
+ import * as stream from 'stream';
8
9
 
9
- export { path, fs, childProcess, events, url, net };
10
+ export { path, fs, childProcess, events, url, net, stream };
10
11
 
11
12
  // @push.rocks scope
12
13
  import * as smartpath from '@push.rocks/smartpath';