Skip to main content

secure_exec_sidecar_core/
frames.rs

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}