@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.
@@ -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,9 +78,16 @@ 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();
87
+ if (options.terminationGracePeriodMs !== undefined
88
+ && (!Number.isSafeInteger(options.terminationGracePeriodMs) || options.terminationGracePeriodMs < 0)) {
89
+ throw new RangeError('RustBridge terminationGracePeriodMs must be a non-negative safe integer');
90
+ }
79
91
  if (options.inheritedFileDescriptors) {
80
92
  for (const descriptor of options.inheritedFileDescriptors) {
81
93
  if (!Number.isSafeInteger(descriptor) || descriptor < 0) {
@@ -85,6 +97,11 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
85
97
  }
86
98
  }
87
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
+ }
88
105
  if (options.maxPendingRequestsByMethod) {
89
106
  for (const [method, limit] of Object.entries(options.maxPendingRequestsByMethod)) {
90
107
  if (!method || !Number.isSafeInteger(limit) || limit <= 0) {
@@ -105,6 +122,9 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
105
122
  inheritedFileDescriptors: options.inheritedFileDescriptors
106
123
  ? [...options.inheritedFileDescriptors]
107
124
  : undefined,
125
+ sensitiveByteChannel: options.sensitiveByteChannel
126
+ ? { ...options.sensitiveByteChannel }
127
+ : undefined,
108
128
  maxPendingRequestsByMethod: options.maxPendingRequestsByMethod
109
129
  ? { ...options.maxPendingRequestsByMethod }
110
130
  : undefined,
@@ -133,7 +153,9 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
133
153
  cliArgs: this.options.cliArgs,
134
154
  env: this.options.env,
135
155
  inheritedFileDescriptors: this.options.inheritedFileDescriptors,
156
+ sensitiveByteChannel: this.options.sensitiveByteChannel,
136
157
  maxPayloadSize: this.options.maxPayloadSize,
158
+ terminationGracePeriodMs: this.options.terminationGracePeriodMs,
137
159
  logger: this.logger,
138
160
  });
139
161
  });
@@ -147,9 +169,9 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
147
169
  * @param socketOptions - Optional socket connection options (reconnect, etc.)
148
170
  */
149
171
  public connect(socketPath: string, socketOptions?: ISocketConnectOptions): Promise<boolean> {
150
- if (this.options.inheritedFileDescriptors?.length) {
172
+ if (this.options.inheritedFileDescriptors?.length || this.options.sensitiveByteChannel) {
151
173
  return Promise.reject(
152
- new Error('RustBridge inheritedFileDescriptors are available only with spawn()'),
174
+ new Error('RustBridge inheritedFileDescriptors and sensitiveByteChannel are available only with spawn()'),
153
175
  );
154
176
  }
155
177
  return this.activate(async () => new SocketTransport({
@@ -187,6 +209,8 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
187
209
  }
188
210
 
189
211
  const generation = ++this.lifecycleGeneration;
212
+ this.sensitiveRequestId = null;
213
+ this.sensitiveUsedGeneration = null;
190
214
  this.lifecycleState = 'starting';
191
215
 
192
216
  const activationPromise = (async (): Promise<boolean> => {
@@ -449,16 +473,25 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
449
473
  );
450
474
  }
451
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
+
452
483
  const transport = this.transport;
453
484
  if (!transport?.connected || !this.isRunning) {
454
485
  throw new Error(`${this.options.binaryName} bridge is not running`);
455
486
  }
456
487
 
457
488
  const lifecycleGeneration = this.lifecycleGeneration;
458
- const admissionReservation = this.reservePendingRequest(method);
489
+ this.regularRequestPreparations++;
490
+ let admissionReservation: IRequestAdmissionReservation | undefined;
459
491
  let reservationTransferred = false;
460
492
 
461
493
  try {
494
+ admissionReservation = this.reservePendingRequest(method);
462
495
  const id = `req_${++this.requestCounter}`;
463
496
  const request: IManagementRequest = { id, method, params };
464
497
  const json = JSON.stringify(request);
@@ -576,12 +609,198 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
576
609
  }
577
610
  });
578
611
  } finally {
612
+ this.regularRequestPreparations--;
579
613
  if (!reservationTransferred) {
580
614
  this.releasePendingRequestReservation(admissionReservation);
581
615
  }
582
616
  }
583
617
  }
584
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
+
585
804
  /**
586
805
  * Send a streaming command to the Rust process.
587
806
  * Returns a StreamingResponse that yields chunks via `for await...of`
@@ -593,6 +812,14 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
593
812
  ): StreamingResponse<TExtractChunk<TCommands[K]>, TCommands[K]['result']> {
594
813
  const streaming = new StreamingResponse<TExtractChunk<TCommands[K]>, TCommands[K]['result']>();
595
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
+
596
823
  const transport = this.transport;
597
824
  if (!transport?.connected || !this.isRunning) {
598
825
  streaming.fail(new Error(`${this.options.binaryName} bridge is not running`));
@@ -603,6 +830,7 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
603
830
  let admissionReservation: IRequestAdmissionReservation | undefined;
604
831
  let reservationTransferred = false;
605
832
  let requestId: string | undefined;
833
+ this.regularRequestPreparations++;
606
834
 
607
835
  try {
608
836
  admissionReservation = this.reservePendingRequest(method);
@@ -704,6 +932,7 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
704
932
  streaming.fail(error);
705
933
  }
706
934
  } finally {
935
+ this.regularRequestPreparations--;
707
936
  if (!reservationTransferred) {
708
937
  this.releasePendingRequestReservation(admissionReservation);
709
938
  }
@@ -747,7 +976,7 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
747
976
 
748
977
  const terminationPromise = (async (): Promise<void> => {
749
978
  if (transport) {
750
- await transport.disconnectAndWait({ gracePeriodMs: optionsArg.gracePeriodMs });
979
+ await transport.disconnectAndWait({ gracePeriodMs: optionsArg.gracePeriodMs ?? this.options.terminationGracePeriodMs });
751
980
  } else if (activationPromise) {
752
981
  // No transport exists yet (for example, binary lookup is pending). The
753
982
  // stopping state prevents that activation from publishing a transport.
@@ -762,7 +991,7 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
762
991
 
763
992
  const lateTransport = this.transport;
764
993
  if (lateTransport && lateTransport !== transport) {
765
- await lateTransport.disconnectAndWait({ gracePeriodMs: optionsArg.gracePeriodMs });
994
+ await lateTransport.disconnectAndWait({ gracePeriodMs: optionsArg.gracePeriodMs ?? this.options.terminationGracePeriodMs });
766
995
  }
767
996
 
768
997
  this.transport = null;
@@ -875,33 +1104,63 @@ export class RustBridge<TCommands extends TCommandMap = TCommandMap> extends plu
875
1104
  const response = parsed as IManagementResponse;
876
1105
  const pending = this.pendingRequests.get(response.id);
877
1106
  if (pending) {
878
- if (response.success) {
879
- this.settlePendingRequest(response.id, {
880
- outcome: 'resolve',
881
- value: response.result,
882
- });
883
- } else {
884
- const message = response.error || 'Unknown error from Rust process';
885
- this.settlePendingRequest(response.id, {
886
- outcome: 'reject',
887
- error: new RustBridgeRequestError(
888
- message,
889
- {
890
- code: 'ERR_RUST_BRIDGE_REQUEST_RUST_RESPONSE',
891
- responseErrorCode:
892
- typeof response.errorCode === 'string'
893
- ? response.errorCode
894
- : undefined,
895
- requestId: response.id,
896
- method: pending.method,
897
- },
898
- ),
899
- });
900
- }
1107
+ if (pending.sensitive) this.acceptSensitiveResponse(response, pending);
1108
+ else this.settleManagementResponse(response, pending);
901
1109
  }
902
1110
  }
903
1111
  }
904
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
+
905
1164
  private settlePendingRequest(
906
1165
  id: string,
907
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 {