@push.rocks/smartrust 2.2.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.
@@ -35,6 +35,11 @@ interface IPendingRequest {
35
35
  abortHandler?: () => void;
36
36
  streaming?: StreamingResponse<any, any>;
37
37
  admissionReservation?: IRequestAdmissionReservation;
38
+ sensitive?: {
39
+ byteLength: number;
40
+ writeComplete: boolean;
41
+ response: IManagementResponse | null;
42
+ };
38
43
  }
39
44
 
40
45
  interface IRequestAdmissionReservation {
@@ -73,6 +78,9 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
73
78
  private lifecycleGeneration = 0;
74
79
  private activationPromise: Promise<boolean> | null = null;
75
80
  private terminationPromise: Promise<void> | null = null;
81
+ private sensitiveRequestId: string | null = null;
82
+ private sensitiveUsedGeneration: number | null = null;
83
+ private regularRequestPreparations = 0;
76
84
 
77
85
  constructor(options: IRustBridgeOptions) {
78
86
  super();
@@ -89,6 +97,11 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
89
97
  }
90
98
  }
91
99
  }
100
+ if (options.sensitiveByteChannel
101
+ && (!Number.isSafeInteger(options.sensitiveByteChannel.maxPayloadSize)
102
+ || options.sensitiveByteChannel.maxPayloadSize <= 0)) {
103
+ throw new RangeError('RustBridge sensitiveByteChannel maxPayloadSize must be a positive safe integer');
104
+ }
92
105
  if (options.maxPendingRequestsByMethod) {
93
106
  for (const [method, limit] of Object.entries(options.maxPendingRequestsByMethod)) {
94
107
  if (!method || !Number.isSafeInteger(limit) || limit <= 0) {
@@ -109,6 +122,9 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
109
122
  inheritedFileDescriptors: options.inheritedFileDescriptors
110
123
  ? [...options.inheritedFileDescriptors]
111
124
  : undefined,
125
+ sensitiveByteChannel: options.sensitiveByteChannel
126
+ ? { ...options.sensitiveByteChannel }
127
+ : undefined,
112
128
  maxPendingRequestsByMethod: options.maxPendingRequestsByMethod
113
129
  ? { ...options.maxPendingRequestsByMethod }
114
130
  : undefined,
@@ -137,6 +153,7 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
137
153
  cliArgs: this.options.cliArgs,
138
154
  env: this.options.env,
139
155
  inheritedFileDescriptors: this.options.inheritedFileDescriptors,
156
+ sensitiveByteChannel: this.options.sensitiveByteChannel,
140
157
  maxPayloadSize: this.options.maxPayloadSize,
141
158
  terminationGracePeriodMs: this.options.terminationGracePeriodMs,
142
159
  logger: this.logger,
@@ -152,9 +169,9 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
152
169
  * @param socketOptions - Optional socket connection options (reconnect, etc.)
153
170
  */
154
171
  public connect(socketPath: string, socketOptions?: ISocketConnectOptions): Promise<boolean> {
155
- if (this.options.inheritedFileDescriptors?.length) {
172
+ if (this.options.inheritedFileDescriptors?.length || this.options.sensitiveByteChannel) {
156
173
  return Promise.reject(
157
- new Error('RustBridge inheritedFileDescriptors are available only with spawn()'),
174
+ new Error('RustBridge inheritedFileDescriptors and sensitiveByteChannel are available only with spawn()'),
158
175
  );
159
176
  }
160
177
  return this.activate(async () => new SocketTransport({
@@ -192,6 +209,8 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
192
209
  }
193
210
 
194
211
  const generation = ++this.lifecycleGeneration;
212
+ this.sensitiveRequestId = null;
213
+ this.sensitiveUsedGeneration = null;
195
214
  this.lifecycleState = 'starting';
196
215
 
197
216
  const activationPromise = (async (): Promise<boolean> => {
@@ -454,16 +473,25 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
454
473
  );
455
474
  }
456
475
 
476
+ if (this.sensitiveRequestId !== null) {
477
+ throw new RustBridgeRequestError(
478
+ `Command '${method}' cannot overlap the sensitive byte request`,
479
+ { code: 'ERR_RUST_BRIDGE_REQUEST_SENSITIVE_STATE', method },
480
+ );
481
+ }
482
+
457
483
  const transport = this.transport;
458
484
  if (!transport?.connected || !this.isRunning) {
459
485
  throw new Error(`${this.options.binaryName} bridge is not running`);
460
486
  }
461
487
 
462
488
  const lifecycleGeneration = this.lifecycleGeneration;
463
- const admissionReservation = this.reservePendingRequest(method);
489
+ this.regularRequestPreparations++;
490
+ let admissionReservation: IRequestAdmissionReservation | undefined;
464
491
  let reservationTransferred = false;
465
492
 
466
493
  try {
494
+ admissionReservation = this.reservePendingRequest(method);
467
495
  const id = `req_${++this.requestCounter}`;
468
496
  const request: IManagementRequest = { id, method, params };
469
497
  const json = JSON.stringify(request);
@@ -581,12 +609,198 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
581
609
  }
582
610
  });
583
611
  } finally {
612
+ this.regularRequestPreparations--;
584
613
  if (!reservationTransferred) {
585
614
  this.releasePendingRequestReservation(admissionReservation);
586
615
  }
587
616
  }
588
617
  }
589
618
 
619
+ /**
620
+ * Send one request whose raw sensitive bytes travel on the generation-owned
621
+ * binary pipe. JSON carries only correlation, descriptor and length metadata.
622
+ *
623
+ * The supplied mutable view is exclusively owned by this call until it
624
+ * settles. Smartrust clears that exact view after the local write callback or
625
+ * failure; it cannot promise erasure of aliases, runtime or kernel copies, or
626
+ * receiver-owned memory.
627
+ */
628
+ public async sendCommandWithSensitiveBytes<K extends string & keyof TCommands>(
629
+ method: K,
630
+ params: TCommands[K]['params'],
631
+ bytes: Uint8Array<ArrayBuffer>,
632
+ options?: IRustBridgeRequestOptions,
633
+ ): Promise<TCommands[K]['result']> {
634
+ let cleared = false;
635
+ let admitted = false;
636
+ let reservationTransferred = false;
637
+ let admissionReservation: IRequestAdmissionReservation | undefined;
638
+ let ownedRequestId: string | null = null;
639
+ let ownedBytes: Uint8Array<ArrayBuffer> | null = null;
640
+ const clear = (): void => {
641
+ if (!cleared && ownedBytes) {
642
+ Uint8Array.prototype.fill.call(ownedBytes, 0);
643
+ cleared = true;
644
+ }
645
+ };
646
+
647
+ try {
648
+ const timeoutMs = options?.timeoutMs ?? this.options.requestTimeoutMs;
649
+ if (options?.timeoutMs !== undefined
650
+ && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0)) {
651
+ throw new RangeError('RustBridge request timeoutMs must be a positive safe integer');
652
+ }
653
+ if (!(bytes instanceof Uint8Array) || !(bytes.buffer instanceof ArrayBuffer)) {
654
+ throw new TypeError('RustBridge sensitive bytes must use an ArrayBuffer-backed Uint8Array');
655
+ }
656
+ ownedBytes = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
657
+ const maxPayloadSize = this.options.sensitiveByteChannel?.maxPayloadSize;
658
+ if (maxPayloadSize === undefined) {
659
+ throw new RustBridgeRequestError(
660
+ `Command '${method}' requires sensitiveByteChannel`,
661
+ { code: 'ERR_RUST_BRIDGE_REQUEST_SENSITIVE_STATE', method },
662
+ );
663
+ }
664
+ if (ownedBytes.byteLength > maxPayloadSize) {
665
+ throw new RangeError(
666
+ `Sensitive byte payload exceeds maxPayloadSize (${ownedBytes.byteLength} > ${maxPayloadSize})`,
667
+ );
668
+ }
669
+ if (options?.signal?.aborted) {
670
+ throw new RustBridgeRequestError(
671
+ `Command '${method}' was aborted before it was sent`,
672
+ { code: 'ERR_RUST_BRIDGE_REQUEST_ABORTED', method, cause: options.signal.reason },
673
+ );
674
+ }
675
+
676
+ const transport = this.transport;
677
+ const lifecycleGeneration = this.lifecycleGeneration;
678
+ if (!transport?.connected || !this.isRunning) {
679
+ throw new Error(`${this.options.binaryName} bridge is not running`);
680
+ }
681
+ if (!transport.writeSensitiveFrame || !Number.isSafeInteger(transport.sensitiveFileDescriptor)
682
+ || (transport.sensitiveFileDescriptor as number) < 3) {
683
+ throw new RustBridgeRequestError(
684
+ `Command '${method}' has no owned sensitive byte pipe`,
685
+ { code: 'ERR_RUST_BRIDGE_REQUEST_SENSITIVE_STATE', method },
686
+ );
687
+ }
688
+ if (this.sensitiveRequestId !== null || this.sensitiveUsedGeneration === lifecycleGeneration
689
+ || this.pendingRequests.size !== 0 || this.regularRequestPreparations !== 0) {
690
+ throw new RustBridgeRequestError(
691
+ `Command '${method}' cannot acquire the one-shot sensitive byte pipe`,
692
+ { code: 'ERR_RUST_BRIDGE_REQUEST_SENSITIVE_STATE', method },
693
+ );
694
+ }
695
+
696
+ admissionReservation = this.reservePendingRequest(method);
697
+ const id = `req_${++this.requestCounter}`;
698
+ ownedRequestId = id;
699
+ this.sensitiveRequestId = id;
700
+ this.sensitiveUsedGeneration = lifecycleGeneration;
701
+ admitted = true;
702
+ const request: IManagementRequest = {
703
+ id,
704
+ method,
705
+ params,
706
+ sensitive: {
707
+ protocol: 'smartrust-sensitive-v1',
708
+ fileDescriptor: transport.sensitiveFileDescriptor as number,
709
+ byteLength: ownedBytes.byteLength,
710
+ },
711
+ };
712
+ const json = JSON.stringify(request);
713
+ const byteLength = Buffer.byteLength(json, 'utf8');
714
+ if (byteLength > this.options.maxPayloadSize) {
715
+ throw new Error(
716
+ `Outbound message exceeds maxPayloadSize (${byteLength} > ${this.options.maxPayloadSize})`,
717
+ );
718
+ }
719
+ const availabilityError = this.getRequestAvailabilityError(
720
+ method, options?.signal, transport, lifecycleGeneration,
721
+ );
722
+ if (availabilityError) throw availabilityError;
723
+
724
+ const result = await new Promise<TCommands[K]['result']>((resolve, reject) => {
725
+ const pending: IPendingRequest = {
726
+ resolve,
727
+ reject,
728
+ timer: null,
729
+ method,
730
+ abortSignal: options?.signal,
731
+ admissionReservation,
732
+ sensitive: { byteLength: ownedBytes!.byteLength, writeComplete: false, response: null },
733
+ };
734
+ this.pendingRequests.set(id, pending);
735
+ reservationTransferred = true;
736
+
737
+ if (options?.signal) {
738
+ pending.abortHandler = () => {
739
+ this.settlePendingRequest(id, {
740
+ outcome: 'reject',
741
+ error: new RustBridgeRequestError(`Command '${method}' was aborted`, {
742
+ code: 'ERR_RUST_BRIDGE_REQUEST_ABORTED', requestId: id, method,
743
+ cause: options.signal?.reason,
744
+ }),
745
+ });
746
+ };
747
+ options.signal.addEventListener('abort', pending.abortHandler, { once: true });
748
+ }
749
+ pending.timer = setTimeout(() => {
750
+ this.settlePendingRequest(id, {
751
+ outcome: 'reject',
752
+ error: new RustBridgeRequestError(`Command '${method}' timed out after ${timeoutMs}ms`, {
753
+ code: 'ERR_RUST_BRIDGE_REQUEST_TIMEOUT', requestId: id, method,
754
+ }),
755
+ });
756
+ }, timeoutMs);
757
+
758
+ void (async (): Promise<void> => {
759
+ try {
760
+ const currentAvailabilityError = this.getRequestAvailabilityError(
761
+ method, options?.signal, transport, lifecycleGeneration, id,
762
+ );
763
+ if (currentAvailabilityError) throw currentAvailabilityError;
764
+ await transport.write(json + '\n');
765
+ await transport.writeSensitiveFrame!(id, ownedBytes!);
766
+ clear();
767
+ this.markSensitiveWriteComplete(id);
768
+ } catch (error: unknown) {
769
+ try { clear(); } catch (clearError: unknown) {
770
+ error = new AggregateError([error, clearError], 'Sensitive byte write and clearing failed');
771
+ }
772
+ this.settlePendingRequest(id, {
773
+ outcome: 'reject',
774
+ error: error instanceof RustBridgeRequestError
775
+ ? error
776
+ : new RustBridgeRequestError(
777
+ `Failed to write sensitive byte request: ${this.getErrorMessage(error)}`,
778
+ { code: 'ERR_RUST_BRIDGE_REQUEST_WRITE', requestId: id, method, cause: error },
779
+ ),
780
+ });
781
+ }
782
+ })();
783
+ });
784
+ return result;
785
+ } catch (error: unknown) {
786
+ if (admitted) {
787
+ try {
788
+ await this.terminate();
789
+ } catch (terminationError: unknown) {
790
+ throw new AggregateError([error, terminationError], 'Sensitive request failed and child termination was not confirmed');
791
+ }
792
+ }
793
+ throw error;
794
+ } finally {
795
+ try { clear(); } finally {
796
+ if (!reservationTransferred) this.releasePendingRequestReservation(admissionReservation);
797
+ if (ownedRequestId !== null && this.sensitiveRequestId === ownedRequestId) {
798
+ this.sensitiveRequestId = null;
799
+ }
800
+ }
801
+ }
802
+ }
803
+
590
804
  /**
591
805
  * Send a streaming command to the Rust process.
592
806
  * Returns a StreamingResponse that yields chunks via `for await...of`
@@ -598,6 +812,14 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
598
812
  ): StreamingResponse<TExtractChunk<TCommands[K]>, TCommands[K]['result']> {
599
813
  const streaming = new StreamingResponse<TExtractChunk<TCommands[K]>, TCommands[K]['result']>();
600
814
 
815
+ if (this.sensitiveRequestId !== null) {
816
+ streaming.fail(new RustBridgeRequestError(
817
+ `Command '${method}' cannot overlap the sensitive byte request`,
818
+ { code: 'ERR_RUST_BRIDGE_REQUEST_SENSITIVE_STATE', method },
819
+ ));
820
+ return streaming;
821
+ }
822
+
601
823
  const transport = this.transport;
602
824
  if (!transport?.connected || !this.isRunning) {
603
825
  streaming.fail(new Error(`${this.options.binaryName} bridge is not running`));
@@ -608,6 +830,7 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
608
830
  let admissionReservation: IRequestAdmissionReservation | undefined;
609
831
  let reservationTransferred = false;
610
832
  let requestId: string | undefined;
833
+ this.regularRequestPreparations++;
611
834
 
612
835
  try {
613
836
  admissionReservation = this.reservePendingRequest(method);
@@ -709,6 +932,7 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
709
932
  streaming.fail(error);
710
933
  }
711
934
  } finally {
935
+ this.regularRequestPreparations--;
712
936
  if (!reservationTransferred) {
713
937
  this.releasePendingRequestReservation(admissionReservation);
714
938
  }
@@ -880,33 +1104,63 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
880
1104
  const response = parsed as IManagementResponse;
881
1105
  const pending = this.pendingRequests.get(response.id);
882
1106
  if (pending) {
883
- if (response.success) {
884
- this.settlePendingRequest(response.id, {
885
- outcome: 'resolve',
886
- value: response.result,
887
- });
888
- } else {
889
- const message = response.error || 'Unknown error from Rust process';
890
- this.settlePendingRequest(response.id, {
891
- outcome: 'reject',
892
- error: new RustBridgeRequestError(
893
- message,
894
- {
895
- code: 'ERR_RUST_BRIDGE_REQUEST_RUST_RESPONSE',
896
- responseErrorCode:
897
- typeof response.errorCode === 'string'
898
- ? response.errorCode
899
- : undefined,
900
- requestId: response.id,
901
- method: pending.method,
902
- },
903
- ),
904
- });
905
- }
1107
+ if (pending.sensitive) this.acceptSensitiveResponse(response, pending);
1108
+ else this.settleManagementResponse(response, pending);
906
1109
  }
907
1110
  }
908
1111
  }
909
1112
 
1113
+ private acceptSensitiveResponse(response: IManagementResponse, pending: IPendingRequest): void {
1114
+ const state = pending.sensitive!;
1115
+ const ack = response.sensitiveAck;
1116
+ if (state.response !== null || typeof response.success !== 'boolean'
1117
+ || !ack || typeof ack !== 'object' || Array.isArray(ack)
1118
+ || Object.keys(ack).sort().join(',') !== 'byteLength,consumed,protocol'
1119
+ || ack.protocol !== 'smartrust-sensitive-v1' || ack.consumed !== true
1120
+ || ack.byteLength !== state.byteLength) {
1121
+ this.settlePendingRequest(response.id, {
1122
+ outcome: 'reject',
1123
+ error: new RustBridgeRequestError(
1124
+ `Command '${pending.method}' returned an invalid sensitive byte acknowledgement`,
1125
+ {
1126
+ code: 'ERR_RUST_BRIDGE_REQUEST_SENSITIVE_PROTOCOL',
1127
+ requestId: response.id,
1128
+ method: pending.method,
1129
+ },
1130
+ ),
1131
+ });
1132
+ return;
1133
+ }
1134
+ state.response = response;
1135
+ if (state.writeComplete) this.settleManagementResponse(response, pending);
1136
+ }
1137
+
1138
+ private markSensitiveWriteComplete(id: string): void {
1139
+ const pending = this.pendingRequests.get(id);
1140
+ if (!pending?.sensitive) return;
1141
+ pending.sensitive.writeComplete = true;
1142
+ if (pending.sensitive.response) {
1143
+ this.settleManagementResponse(pending.sensitive.response, pending);
1144
+ }
1145
+ }
1146
+
1147
+ private settleManagementResponse(response: IManagementResponse, pending: IPendingRequest): void {
1148
+ if (response.success) {
1149
+ this.settlePendingRequest(response.id, { outcome: 'resolve', value: response.result });
1150
+ return;
1151
+ }
1152
+ const message = response.error || 'Unknown error from Rust process';
1153
+ this.settlePendingRequest(response.id, {
1154
+ outcome: 'reject',
1155
+ error: new RustBridgeRequestError(message, {
1156
+ code: 'ERR_RUST_BRIDGE_REQUEST_RUST_RESPONSE',
1157
+ responseErrorCode: typeof response.errorCode === 'string' ? response.errorCode : undefined,
1158
+ requestId: response.id,
1159
+ method: pending.method,
1160
+ }),
1161
+ });
1162
+ }
1163
+
910
1164
  private settlePendingRequest(
911
1165
  id: string,
912
1166
  settlement: TPendingRequestSettlement,
@@ -7,6 +7,8 @@ export type TRustBridgeRequestErrorCode =
7
7
  | 'ERR_RUST_BRIDGE_REQUEST_LIMIT'
8
8
  | 'ERR_RUST_BRIDGE_REQUEST_WRITE'
9
9
  | 'ERR_RUST_BRIDGE_REQUEST_TRANSPORT'
10
+ | 'ERR_RUST_BRIDGE_REQUEST_SENSITIVE_STATE'
11
+ | 'ERR_RUST_BRIDGE_REQUEST_SENSITIVE_PROTOCOL'
10
12
  | 'ERR_RUST_BRIDGE_REQUEST_RUST_RESPONSE';
11
13
 
12
14
  export interface IRustBridgeRequestErrorOptions {
@@ -8,12 +8,15 @@ 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;
18
21
  /** Default for every cleanup path, including automatic stdin failure. */
19
22
  terminationGracePeriodMs?: number;
@@ -34,6 +37,9 @@ interface IStdioTransportGeneration {
34
37
  sigtermAttempted: boolean;
35
38
  sigkillAttempted: boolean;
36
39
  sigtermError: Error | null;
40
+ sensitiveWriter: plugins.stream.Writable | null;
41
+ sensitiveUsed: boolean;
42
+ sensitiveError: Error | null;
37
43
  }
38
44
 
39
45
  /**
@@ -53,7 +59,17 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
53
59
  && (!Number.isSafeInteger(options.terminationGracePeriodMs) || options.terminationGracePeriodMs < 0)) {
54
60
  throw new RangeError('StdioTransport terminationGracePeriodMs must be a non-negative safe integer');
55
61
  }
56
- this.options = { ...options };
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
+ };
57
73
  this.lineScanner = new LineScanner(options.maxPayloadSize, options.logger);
58
74
  }
59
75
 
@@ -61,6 +77,12 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
61
77
  return this._connected;
62
78
  }
63
79
 
80
+ public get sensitiveFileDescriptor(): number | null {
81
+ return this.options.sensitiveByteChannel
82
+ ? 3 + (this.options.inheritedFileDescriptors?.length ?? 0)
83
+ : null;
84
+ }
85
+
64
86
  /**
65
87
  * Spawn the child process. Resolves when the process is running (not necessarily ready).
66
88
  */
@@ -82,6 +104,7 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
82
104
  'pipe',
83
105
  'pipe',
84
106
  ...(this.options.inheritedFileDescriptors ?? []),
107
+ ...(this.options.sensitiveByteChannel ? ['pipe' as const] : []),
85
108
  ];
86
109
  childProcess = plugins.childProcess.spawn(
87
110
  this.options.binaryPath,
@@ -96,6 +119,12 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
96
119
  const closePromise = new Promise<void>((resolve) => {
97
120
  resolveClose = resolve;
98
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;
99
128
  const generation: IStdioTransportGeneration = {
100
129
  childProcess,
101
130
  stderrRemainder: '',
@@ -110,6 +139,9 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
110
139
  sigtermAttempted: false,
111
140
  sigkillAttempted: false,
112
141
  sigtermError: null,
142
+ sensitiveWriter,
143
+ sensitiveUsed: false,
144
+ sensitiveError: null,
113
145
  };
114
146
 
115
147
  this.generation = generation;
@@ -151,6 +183,23 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
151
183
  });
152
184
  });
153
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
+
154
203
  // Handle stderr with cross-chunk buffering.
155
204
  childProcess.stderr?.on('data', (data: Buffer) => {
156
205
  if (this.generation !== generation || generation.closed) {
@@ -198,6 +247,7 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
198
247
  this.destroyOwnedStdioEndpoint(generation, 'stdin');
199
248
  this.destroyOwnedStdioEndpoint(generation, 'stdout');
200
249
  this.destroyOwnedStdioEndpoint(generation, 'stderr');
250
+ this.destroySensitiveEndpoint(generation);
201
251
  });
202
252
  });
203
253
 
@@ -285,6 +335,49 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
285
335
  });
286
336
  }
287
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
+
288
381
  /**
289
382
  * Kill the child process without waiting for confirmation.
290
383
  */
@@ -338,6 +431,7 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
338
431
  generation.disconnectPromise = disconnectPromise;
339
432
  generation.rejectDisconnect = rejectDisconnect;
340
433
  this._connected = false;
434
+ this.destroySensitiveEndpoint(generation);
341
435
  void disconnectPromise.then(
342
436
  () => {
343
437
  if (generation.disconnectPromise === disconnectPromise) {
@@ -364,6 +458,7 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
364
458
  this.destroyOwnedStdioEndpoint(generation, 'stdin');
365
459
  this.destroyOwnedStdioEndpoint(generation, 'stdout');
366
460
  this.destroyOwnedStdioEndpoint(generation, 'stderr');
461
+ this.destroySensitiveEndpoint(generation);
367
462
  return disconnectPromise;
368
463
  }
369
464
 
@@ -486,6 +581,55 @@ export class StdioTransport extends plugins.events.EventEmitter implements IRust
486
581
  }
487
582
  }
488
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
+
489
633
  private toError(error: unknown, prefix: string): Error {
490
634
  return new Error(prefix + ': ' + this.getErrorMessage(error));
491
635
  }
@@ -61,6 +61,15 @@ export interface IRustBridgeOptions extends IBinaryLocatorOptions {
61
61
  * closes the caller's source descriptors.
62
62
  */
63
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
+ };
64
73
  /** Timeout for individual requests in ms (default: 30000) */
65
74
  requestTimeoutMs?: number;
66
75
  /** Timeout for the ready event during spawn in ms (default: 10000) */