1use secure_exec_sidecar_protocol::protocol::{
2 AuthenticateRequest, AuthenticatedResponse, BoundUdpSnapshotResponse, EventFrame, EventPayload,
3 LayerCreatedResponse, LayerSealedResponse, ListenerSnapshotResponse, OverlayCreatedResponse,
4 OwnershipScope, PackageLinkedResponse, ProcessExitedEvent, ProcessKilledResponse,
5 ProcessOutputEvent, ProcessSnapshotEntry, ProcessSnapshotResponse, ProcessStartedResponse,
6 ProtocolSchema, RejectedResponse, RequestFrame, RequestId, ResponseFrame, ResponsePayload,
7 RootFilesystemBootstrappedResponse, RootFilesystemEntry, RootFilesystemSnapshotResponse,
8 SessionOpenedResponse, SignalHandlerRegistration, SignalStateResponse,
9 SnapshotExportedResponse, SnapshotImportedResponse, SocketStateEntry, StdinClosedResponse,
10 StdinWrittenResponse, StreamChannel, StructuredEvent, VmConfiguredResponse, VmCreatedResponse,
11 VmDisposedResponse, VmLifecycleEvent, VmLifecycleState, ZombieTimerCountResponse,
12 PROTOCOL_VERSION,
13};
14use std::collections::HashMap;
15
16pub const UNSUPPORTED_GUEST_KERNEL_CALL_EVENT: &str = "guest.kernel_call.unsupported";
17
18#[derive(Debug, Clone)]
19pub struct DispatchResult {
20 pub response: ResponseFrame,
21 pub events: Vec<EventFrame>,
22}
23
24pub fn response_with_ownership(
25 request_id: RequestId,
26 ownership: OwnershipScope,
27 payload: ResponsePayload,
28) -> ResponseFrame {
29 ResponseFrame {
30 schema: ProtocolSchema::current(),
31 request_id,
32 ownership,
33 payload,
34 }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum AuthenticateVersionError {
39 ProtocolVersionMismatch(String),
40 BridgeVersionMismatch(String),
41}
42
43impl AuthenticateVersionError {
44 pub fn code(&self) -> &'static str {
45 match self {
46 Self::ProtocolVersionMismatch(_) => "protocol_version_mismatch",
47 Self::BridgeVersionMismatch(_) => "bridge_version_mismatch",
48 }
49 }
50
51 pub fn message(&self) -> &str {
52 match self {
53 Self::ProtocolVersionMismatch(message) | Self::BridgeVersionMismatch(message) => {
54 message
55 }
56 }
57 }
58}
59
60pub fn validate_authenticate_versions(
61 payload: &AuthenticateRequest,
62) -> Result<(), AuthenticateVersionError> {
63 if payload.protocol_version != PROTOCOL_VERSION {
64 return Err(AuthenticateVersionError::ProtocolVersionMismatch(format!(
65 "sidecar protocol version mismatch: expected {}, got {}",
66 PROTOCOL_VERSION, payload.protocol_version
67 )));
68 }
69
70 let expected_bridge_version = secure_exec_bridge::bridge_contract().version;
71 if payload.bridge_version != expected_bridge_version {
72 return Err(AuthenticateVersionError::BridgeVersionMismatch(format!(
73 "bridge contract version mismatch: expected {expected_bridge_version}, got {}",
74 payload.bridge_version
75 )));
76 }
77
78 Ok(())
79}
80
81pub fn authenticated_response(
82 request_id: RequestId,
83 sidecar_id: impl Into<String>,
84 connection_id: String,
85 max_frame_bytes: u32,
86) -> ResponseFrame {
87 response_with_ownership(
88 request_id,
89 OwnershipScope::connection(&connection_id),
90 ResponsePayload::Authenticated(AuthenticatedResponse {
91 sidecar_id: sidecar_id.into(),
92 connection_id,
93 max_frame_bytes,
94 }),
95 )
96}
97
98pub fn session_opened_response(
99 request_id: RequestId,
100 owner_connection_id: String,
101 session_id: String,
102) -> ResponseFrame {
103 response_with_ownership(
104 request_id,
105 OwnershipScope::session(&owner_connection_id, &session_id),
106 ResponsePayload::SessionOpened(SessionOpenedResponse {
107 session_id,
108 owner_connection_id,
109 }),
110 )
111}
112
113pub fn respond(request: &RequestFrame, payload: ResponsePayload) -> ResponseFrame {
114 response_with_ownership(request.request_id, request.ownership.clone(), payload)
115}
116
117pub fn reject(request: &RequestFrame, code: &str, message: &str) -> ResponseFrame {
118 respond(
119 request,
120 ResponsePayload::Rejected(RejectedResponse {
121 code: code.to_owned(),
122 message: message.to_owned(),
123 }),
124 )
125}
126
127pub fn vm_created_response(request: &RequestFrame, vm_id: String) -> ResponseFrame {
128 respond(
129 request,
130 ResponsePayload::VmCreated(VmCreatedResponse { vm_id }),
131 )
132}
133
134pub fn vm_disposed_response(request: &RequestFrame, vm_id: String) -> ResponseFrame {
135 respond(
136 request,
137 ResponsePayload::VmDisposed(VmDisposedResponse { vm_id }),
138 )
139}
140
141pub fn root_filesystem_bootstrapped_response(
142 request: &RequestFrame,
143 entry_count: u32,
144) -> ResponseFrame {
145 respond(
146 request,
147 ResponsePayload::RootFilesystemBootstrapped(RootFilesystemBootstrappedResponse {
148 entry_count,
149 }),
150 )
151}
152
153pub fn vm_configured_response(
154 request: &RequestFrame,
155 applied_mounts: u32,
156 applied_software: u32,
157) -> ResponseFrame {
158 respond(
159 request,
160 ResponsePayload::VmConfigured(VmConfiguredResponse {
161 applied_mounts,
162 applied_software,
163 }),
164 )
165}
166
167pub fn package_linked_response(request: &RequestFrame, commands: Vec<String>) -> ResponseFrame {
168 respond(
169 request,
170 ResponsePayload::PackageLinked(PackageLinkedResponse { commands }),
171 )
172}
173
174pub fn layer_created_response(request: &RequestFrame, layer_id: String) -> ResponseFrame {
175 respond(
176 request,
177 ResponsePayload::LayerCreated(LayerCreatedResponse { layer_id }),
178 )
179}
180
181pub fn layer_sealed_response(request: &RequestFrame, layer_id: String) -> ResponseFrame {
182 respond(
183 request,
184 ResponsePayload::LayerSealed(LayerSealedResponse { layer_id }),
185 )
186}
187
188pub fn snapshot_imported_response(request: &RequestFrame, layer_id: String) -> ResponseFrame {
189 respond(
190 request,
191 ResponsePayload::SnapshotImported(SnapshotImportedResponse { layer_id }),
192 )
193}
194
195pub fn snapshot_exported_response(
196 request: &RequestFrame,
197 layer_id: String,
198 entries: Vec<RootFilesystemEntry>,
199) -> ResponseFrame {
200 respond(
201 request,
202 ResponsePayload::SnapshotExported(SnapshotExportedResponse { layer_id, entries }),
203 )
204}
205
206pub fn overlay_created_response(request: &RequestFrame, layer_id: String) -> ResponseFrame {
207 respond(
208 request,
209 ResponsePayload::OverlayCreated(OverlayCreatedResponse { layer_id }),
210 )
211}
212
213pub fn root_filesystem_snapshot_response(
214 request: &RequestFrame,
215 entries: Vec<RootFilesystemEntry>,
216) -> ResponseFrame {
217 respond(
218 request,
219 ResponsePayload::RootFilesystemSnapshot(RootFilesystemSnapshotResponse { entries }),
220 )
221}
222
223pub fn process_started_response(
224 request: &RequestFrame,
225 process_id: String,
226 pid: Option<u32>,
227) -> ResponseFrame {
228 respond(
229 request,
230 ResponsePayload::ProcessStarted(ProcessStartedResponse { process_id, pid }),
231 )
232}
233
234pub fn stdin_written_response(
235 request: &RequestFrame,
236 process_id: String,
237 accepted_bytes: u64,
238) -> ResponseFrame {
239 respond(
240 request,
241 ResponsePayload::StdinWritten(StdinWrittenResponse {
242 process_id,
243 accepted_bytes,
244 }),
245 )
246}
247
248pub fn stdin_closed_response(request: &RequestFrame, process_id: String) -> ResponseFrame {
249 respond(
250 request,
251 ResponsePayload::StdinClosed(StdinClosedResponse { process_id }),
252 )
253}
254
255pub fn process_killed_response(request: &RequestFrame, process_id: String) -> ResponseFrame {
256 respond(
257 request,
258 ResponsePayload::ProcessKilled(ProcessKilledResponse { process_id }),
259 )
260}
261
262pub fn process_snapshot_response(
263 request: &RequestFrame,
264 processes: Vec<ProcessSnapshotEntry>,
265) -> ResponseFrame {
266 respond(
267 request,
268 ResponsePayload::ProcessSnapshot(ProcessSnapshotResponse { processes }),
269 )
270}
271
272pub fn listener_snapshot_response(
273 request: &RequestFrame,
274 listener: Option<SocketStateEntry>,
275) -> ResponseFrame {
276 respond(
277 request,
278 ResponsePayload::ListenerSnapshot(ListenerSnapshotResponse { listener }),
279 )
280}
281
282pub fn bound_udp_snapshot_response(
283 request: &RequestFrame,
284 socket: Option<SocketStateEntry>,
285) -> ResponseFrame {
286 respond(
287 request,
288 ResponsePayload::BoundUdpSnapshot(BoundUdpSnapshotResponse { socket }),
289 )
290}
291
292pub fn signal_state_response(
293 request: &RequestFrame,
294 process_id: String,
295 handlers: impl IntoIterator<Item = (u32, SignalHandlerRegistration)>,
296) -> ResponseFrame {
297 respond(
298 request,
299 ResponsePayload::SignalState(SignalStateResponse {
300 process_id,
301 handlers: handlers.into_iter().collect(),
302 }),
303 )
304}
305
306pub fn zombie_timer_count_response(request: &RequestFrame, count: u64) -> ResponseFrame {
307 respond(
308 request,
309 ResponsePayload::ZombieTimerCount(ZombieTimerCountResponse { count }),
310 )
311}
312
313pub fn event(ownership: OwnershipScope, payload: EventPayload) -> EventFrame {
314 EventFrame::new(ownership, payload)
315}
316
317pub fn vm_lifecycle_event(
318 connection_id: &str,
319 session_id: &str,
320 vm_id: &str,
321 state: VmLifecycleState,
322) -> EventFrame {
323 event(
324 OwnershipScope::vm(connection_id, session_id, vm_id),
325 EventPayload::VmLifecycle(VmLifecycleEvent { state }),
326 )
327}
328
329pub fn process_output_event(
330 ownership: OwnershipScope,
331 process_id: &str,
332 channel: StreamChannel,
333 chunk: Vec<u8>,
334) -> EventFrame {
335 event(
336 ownership,
337 EventPayload::ProcessOutput(ProcessOutputEvent {
338 process_id: process_id.to_owned(),
339 channel,
340 chunk,
341 }),
342 )
343}
344
345pub fn process_exited_event(
346 ownership: OwnershipScope,
347 process_id: &str,
348 exit_code: i32,
349) -> EventFrame {
350 event(
351 ownership,
352 EventPayload::ProcessExited(ProcessExitedEvent {
353 process_id: process_id.to_owned(),
354 exit_code,
355 }),
356 )
357}
358
359pub fn unsupported_guest_kernel_call_event(
360 ownership: OwnershipScope,
361 process_id: &str,
362 execution_id: &str,
363 operation: &str,
364 payload_size_bytes: usize,
365) -> EventFrame {
366 event(
367 ownership,
368 EventPayload::Structured(StructuredEvent {
369 name: String::from(UNSUPPORTED_GUEST_KERNEL_CALL_EVENT),
370 detail: unsupported_guest_kernel_call_detail(
371 Some(process_id),
372 execution_id,
373 operation,
374 payload_size_bytes,
375 ),
376 }),
377 )
378}
379
380pub fn unsupported_guest_kernel_call_detail(
381 process_id: Option<&str>,
382 execution_id: &str,
383 operation: &str,
384 payload_size_bytes: usize,
385) -> HashMap<String, String> {
386 let mut detail = HashMap::from([
387 (String::from("execution_id"), execution_id.to_owned()),
388 (String::from("operation"), operation.to_owned()),
389 (
390 String::from("payload_size_bytes"),
391 payload_size_bytes.to_string(),
392 ),
393 ]);
394 if let Some(process_id) = process_id {
395 detail.insert(String::from("process_id"), process_id.to_owned());
396 }
397 detail
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403 use secure_exec_sidecar_protocol::protocol::RequestPayload;
404
405 fn authenticate_request() -> AuthenticateRequest {
406 AuthenticateRequest {
407 client_name: String::from("test"),
408 auth_token: String::from("token"),
409 protocol_version: secure_exec_sidecar_protocol::protocol::PROTOCOL_VERSION,
410 bridge_version: secure_exec_bridge::bridge_contract().version,
411 }
412 }
413
414 #[test]
415 fn reject_preserves_request_identity_and_ownership() {
416 let request = RequestFrame::new(
417 42,
418 OwnershipScope::connection("conn-1"),
419 RequestPayload::Authenticate(authenticate_request()),
420 );
421
422 let response = reject(&request, "bad_request", "nope");
423
424 assert_eq!(response.request_id, request.request_id);
425 assert_eq!(response.ownership, request.ownership);
426 match response.payload {
427 ResponsePayload::Rejected(rejected) => {
428 assert_eq!(rejected.code, "bad_request");
429 assert_eq!(rejected.message, "nope");
430 }
431 other => panic!("unexpected response payload: {other:?}"),
432 }
433 }
434
435 #[test]
436 fn validates_authenticate_versions() {
437 validate_authenticate_versions(&authenticate_request()).expect("current versions");
438
439 let mut stale_protocol = authenticate_request();
440 stale_protocol.protocol_version = stale_protocol.protocol_version.saturating_sub(1);
441 let error = validate_authenticate_versions(&stale_protocol).expect_err("protocol mismatch");
442 assert_eq!(error.code(), "protocol_version_mismatch");
443 assert!(error
444 .message()
445 .contains("sidecar protocol version mismatch"));
446
447 let mut stale_bridge = authenticate_request();
448 stale_bridge.bridge_version = stale_bridge.bridge_version.saturating_sub(1);
449 let error = validate_authenticate_versions(&stale_bridge).expect_err("bridge mismatch");
450 assert_eq!(error.code(), "bridge_version_mismatch");
451 assert!(error.message().contains("bridge contract version mismatch"));
452 }
453
454 #[test]
455 fn authenticated_response_sets_connection_ownership() {
456 let response =
457 authenticated_response(7, "secure-exec-test", String::from("conn-test"), 1024);
458
459 assert_eq!(response.request_id, 7);
460 assert_eq!(response.ownership, OwnershipScope::connection("conn-test"));
461 match response.payload {
462 ResponsePayload::Authenticated(authenticated) => {
463 assert_eq!(authenticated.sidecar_id, "secure-exec-test");
464 assert_eq!(authenticated.connection_id, "conn-test");
465 assert_eq!(authenticated.max_frame_bytes, 1024);
466 }
467 other => panic!("unexpected response payload: {other:?}"),
468 }
469 }
470
471 #[test]
472 fn session_opened_response_sets_session_ownership() {
473 let response =
474 session_opened_response(8, String::from("conn-1"), String::from("session-1"));
475
476 assert_eq!(response.request_id, 8);
477 assert_eq!(
478 response.ownership,
479 OwnershipScope::session("conn-1", "session-1")
480 );
481 match response.payload {
482 ResponsePayload::SessionOpened(opened) => {
483 assert_eq!(opened.owner_connection_id, "conn-1");
484 assert_eq!(opened.session_id, "session-1");
485 }
486 other => panic!("unexpected response payload: {other:?}"),
487 }
488 }
489
490 #[test]
491 fn lifecycle_response_helpers_preserve_request_ownership() {
492 let request = RequestFrame::new(
493 43,
494 OwnershipScope::vm("conn-1", "session-1", "vm-1"),
495 RequestPayload::Authenticate(authenticate_request()),
496 );
497
498 let created = vm_created_response(&request, String::from("vm-1"));
499 assert_eq!(created.request_id, request.request_id);
500 assert_eq!(created.ownership, request.ownership);
501 match created.payload {
502 ResponsePayload::VmCreated(created) => assert_eq!(created.vm_id, "vm-1"),
503 other => panic!("unexpected response payload: {other:?}"),
504 }
505
506 let bootstrapped = root_filesystem_bootstrapped_response(&request, 3);
507 assert_eq!(bootstrapped.request_id, request.request_id);
508 assert_eq!(bootstrapped.ownership, request.ownership);
509 match bootstrapped.payload {
510 ResponsePayload::RootFilesystemBootstrapped(bootstrapped) => {
511 assert_eq!(bootstrapped.entry_count, 3);
512 }
513 other => panic!("unexpected response payload: {other:?}"),
514 }
515
516 let disposed = vm_disposed_response(&request, String::from("vm-1"));
517 assert_eq!(disposed.request_id, request.request_id);
518 assert_eq!(disposed.ownership, request.ownership);
519 match disposed.payload {
520 ResponsePayload::VmDisposed(disposed) => assert_eq!(disposed.vm_id, "vm-1"),
521 other => panic!("unexpected response payload: {other:?}"),
522 }
523 }
524
525 #[test]
526 fn process_started_response_preserves_pid() {
527 let request = RequestFrame::new(
528 44,
529 OwnershipScope::vm("conn-1", "session-1", "vm-1"),
530 RequestPayload::Authenticate(authenticate_request()),
531 );
532
533 let started = process_started_response(&request, String::from("proc-1"), Some(123));
534 assert_eq!(started.request_id, request.request_id);
535 assert_eq!(started.ownership, request.ownership);
536 match started.payload {
537 ResponsePayload::ProcessStarted(started) => {
538 assert_eq!(started.process_id, "proc-1");
539 assert_eq!(started.pid, Some(123));
540 }
541 other => panic!("unexpected response payload: {other:?}"),
542 }
543
544 let started_without_pid = process_started_response(&request, String::from("proc-2"), None);
545 match started_without_pid.payload {
546 ResponsePayload::ProcessStarted(started) => {
547 assert_eq!(started.process_id, "proc-2");
548 assert_eq!(started.pid, None);
549 }
550 other => panic!("unexpected response payload: {other:?}"),
551 }
552 }
553
554 #[test]
555 fn shared_response_helpers_preserve_payloads() {
556 let request = RequestFrame::new(
557 45,
558 OwnershipScope::vm("conn-1", "session-1", "vm-1"),
559 RequestPayload::Authenticate(authenticate_request()),
560 );
561
562 match vm_configured_response(&request, 2, 3).payload {
563 ResponsePayload::VmConfigured(configured) => {
564 assert_eq!(configured.applied_mounts, 2);
565 assert_eq!(configured.applied_software, 3);
566 }
567 other => panic!("unexpected response payload: {other:?}"),
568 }
569
570 match snapshot_exported_response(&request, String::from("layer-1"), Vec::new()).payload {
571 ResponsePayload::SnapshotExported(exported) => {
572 assert_eq!(exported.layer_id, "layer-1");
573 assert!(exported.entries.is_empty());
574 }
575 other => panic!("unexpected response payload: {other:?}"),
576 }
577
578 match stdin_written_response(&request, String::from("proc-1"), 9).payload {
579 ResponsePayload::StdinWritten(written) => {
580 assert_eq!(written.process_id, "proc-1");
581 assert_eq!(written.accepted_bytes, 9);
582 }
583 other => panic!("unexpected response payload: {other:?}"),
584 }
585
586 match process_killed_response(&request, String::from("proc-1")).payload {
587 ResponsePayload::ProcessKilled(killed) => {
588 assert_eq!(killed.process_id, "proc-1");
589 }
590 other => panic!("unexpected response payload: {other:?}"),
591 }
592
593 match signal_state_response(&request, String::from("proc-1"), []).payload {
594 ResponsePayload::SignalState(state) => {
595 assert_eq!(state.process_id, "proc-1");
596 assert!(state.handlers.is_empty());
597 }
598 other => panic!("unexpected response payload: {other:?}"),
599 }
600
601 match zombie_timer_count_response(&request, 4).payload {
602 ResponsePayload::ZombieTimerCount(count) => assert_eq!(count.count, 4),
603 other => panic!("unexpected response payload: {other:?}"),
604 }
605 }
606
607 #[test]
608 fn process_event_helpers_build_vm_owned_events() {
609 let ownership = OwnershipScope::vm("conn-1", "session-1", "vm-1");
610
611 let output = process_output_event(
612 ownership.clone(),
613 "proc-1",
614 StreamChannel::Stdout,
615 b"hello".to_vec(),
616 );
617 assert_eq!(output.ownership, ownership);
618 match output.payload {
619 EventPayload::ProcessOutput(event) => {
620 assert_eq!(event.process_id, "proc-1");
621 assert_eq!(event.channel, StreamChannel::Stdout);
622 assert_eq!(event.chunk, b"hello");
623 }
624 other => panic!("unexpected event payload: {other:?}"),
625 }
626
627 let exited = process_exited_event(output.ownership, "proc-1", 7);
628 match exited.payload {
629 EventPayload::ProcessExited(event) => {
630 assert_eq!(event.process_id, "proc-1");
631 assert_eq!(event.exit_code, 7);
632 }
633 other => panic!("unexpected event payload: {other:?}"),
634 }
635 }
636
637 #[test]
638 fn unsupported_guest_kernel_call_event_preserves_execution_identity() {
639 let ownership = OwnershipScope::vm("conn-1", "session-1", "vm-1");
640 let event = unsupported_guest_kernel_call_event(
641 ownership.clone(),
642 "proc-1",
643 "exec-1",
644 "fs.read",
645 17,
646 );
647
648 assert_eq!(event.ownership, ownership);
649 match event.payload {
650 EventPayload::Structured(event) => {
651 assert_eq!(event.name, "guest.kernel_call.unsupported");
652 assert_eq!(event.detail["process_id"], "proc-1");
653 assert_eq!(event.detail["execution_id"], "exec-1");
654 assert_eq!(event.detail["operation"], "fs.read");
655 assert_eq!(event.detail["payload_size_bytes"], "17");
656 }
657 other => panic!("unexpected event payload: {other:?}"),
658 }
659 }
660
661 #[test]
662 fn unsupported_guest_kernel_call_detail_can_omit_process_identity() {
663 let detail = unsupported_guest_kernel_call_detail(None, "exec-1", "fs.read", 17);
664
665 assert_eq!(detail["execution_id"], "exec-1");
666 assert_eq!(detail["operation"], "fs.read");
667 assert_eq!(detail["payload_size_bytes"], "17");
668 assert!(!detail.contains_key("process_id"));
669 }
670}