@push.rocks/smartrust 2.2.0 → 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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.rustbridge.d.ts +16 -0
- package/dist_ts/classes.rustbridge.js +245 -24
- package/dist_ts/classes.rustbridgerequesterror.d.ts +4 -1
- package/dist_ts/classes.rustbridgerequesterror.js +4 -1
- package/dist_ts/classes.stdiotransport.d.ts +12 -0
- package/dist_ts/classes.stdiotransport.js +147 -2
- package/dist_ts/interfaces/config.d.ts +9 -0
- package/dist_ts/interfaces/ipc.d.ts +16 -0
- package/dist_ts/interfaces/transport.d.ts +4 -0
- package/dist_ts/plugins.d.ts +2 -1
- package/dist_ts/plugins.js +3 -2
- package/package.json +5 -5
- package/readme.md +86 -6
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.rustbridge.ts +281 -26
- package/ts/classes.rustbridgerequesterror.ts +6 -0
- package/ts/classes.stdiotransport.ts +145 -1
- package/ts/interfaces/config.ts +9 -0
- package/ts/interfaces/ipc.ts +18 -0
- package/ts/interfaces/transport.ts +6 -0
- package/ts/plugins.ts +2 -1
package/ts/classes.rustbridge.ts
CHANGED
|
@@ -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
|
-
|
|
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,64 @@ 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 (
|
|
884
|
-
|
|
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
|
+
responseErrorData: response.errorData,
|
|
1159
|
+
requestId: response.id,
|
|
1160
|
+
method: pending.method,
|
|
1161
|
+
}),
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
|
|
910
1165
|
private settlePendingRequest(
|
|
911
1166
|
id: string,
|
|
912
1167
|
settlement: TPendingRequestSettlement,
|
|
@@ -7,11 +7,14 @@ 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 {
|
|
13
15
|
code: TRustBridgeRequestErrorCode;
|
|
14
16
|
responseErrorCode?: string;
|
|
17
|
+
responseErrorData?: unknown;
|
|
15
18
|
requestId?: string;
|
|
16
19
|
method?: string;
|
|
17
20
|
cause?: unknown;
|
|
@@ -27,6 +30,8 @@ export interface IRustBridgeRequestErrorOptions {
|
|
|
27
30
|
export class RustBridgeRequestError extends Error {
|
|
28
31
|
public readonly code: TRustBridgeRequestErrorCode;
|
|
29
32
|
public readonly responseErrorCode?: string;
|
|
33
|
+
/** Structured details from the Rust error response's `errorData`, as sent. */
|
|
34
|
+
public readonly responseErrorData?: unknown;
|
|
30
35
|
public readonly requestId?: string;
|
|
31
36
|
public readonly method?: string;
|
|
32
37
|
|
|
@@ -35,6 +40,7 @@ export class RustBridgeRequestError extends Error {
|
|
|
35
40
|
this.name = 'RustBridgeRequestError';
|
|
36
41
|
this.code = options.code;
|
|
37
42
|
this.responseErrorCode = options.responseErrorCode;
|
|
43
|
+
this.responseErrorData = options.responseErrorData;
|
|
38
44
|
this.requestId = options.requestId;
|
|
39
45
|
this.method = options.method;
|
|
40
46
|
}
|
|
@@ -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
|
-
|
|
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
|
}
|