Skip to main content

a3s_box_core/
exec.rs

1//! Exec types for host-to-guest command execution.
2//!
3//! Shared request/response types used by both the guest exec server
4//! and the host exec client.
5
6use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9
10/// Vsock port for the exec server.
11pub const EXEC_VSOCK_PORT: u32 = a3s_transport::ports::EXEC_SERVER;
12
13/// Vsock port for the Windows host-port forward control channel.
14pub const PORT_FWD_VSOCK_PORT: u32 = 4093;
15
16/// Host-control frame that asks guest init to signal the container main process.
17///
18/// Windows shares the existing long-lived port-forward channel because WHPX
19/// named-pipe mappings are guest-initiated. The payload is one big-endian `i32`
20/// Linux signal number.
21pub const WINDOWS_CONTROL_SIGNAL_FRAME: u8 = 5;
22
23/// Host-only request file watched by the Windows control worker.
24pub const WINDOWS_STOP_REQUEST_FILE: &str = "stop.signal";
25
26/// Temporary sibling used to publish a Windows stop request atomically.
27pub const WINDOWS_STOP_REQUEST_TEMP_FILE: &str = "stop.signal.tmp";
28
29/// Default exec timeout: 5 seconds.
30pub const DEFAULT_EXEC_TIMEOUT_NS: u64 = 5_000_000_000;
31
32/// Maximum buffered streaming output size per stream (stdout/stderr): 16 MiB.
33pub const MAX_OUTPUT_BYTES: usize = 16 * 1024 * 1024;
34
35/// Maximum captured one-shot output size per stream: 1 MiB.
36///
37/// One-shot responses retain the legacy JSON `Vec<u8>` representation, whose
38/// worst-case encoding uses four bytes per input byte. Bounding both streams
39/// at 1 MiB guarantees the complete response fits the transport's 16 MiB
40/// frame without breaking older host or guest binaries.
41pub const MAX_ONE_SHOT_OUTPUT_BYTES: usize = 1024 * 1024;
42
43/// Frame type byte for streaming exec chunks.
44pub const FRAME_EXEC_CHUNK: u8 = 0x01;
45
46/// Frame type byte for streaming exec exit.
47pub const FRAME_EXEC_EXIT: u8 = 0x02;
48
49/// Request to execute a command in the guest.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct ExecRequest {
52    /// Optional idempotency key for one-shot execution.
53    ///
54    /// A guest that supports replay must execute the same keyed request at
55    /// most once while the result remains in its bounded replay cache. The key
56    /// is deliberately optional for wire compatibility with older clients.
57    /// Streaming execution cannot provide an exact one-shot result replay and
58    /// therefore must not set this field.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub request_id: Option<String>,
61    /// Command and arguments (e.g., ["ls", "-la"]).
62    pub cmd: Vec<String>,
63    /// Timeout in nanoseconds. 0 means use the default.
64    pub timeout_ns: u64,
65    /// Additional environment variables (KEY=VALUE pairs).
66    #[serde(default)]
67    pub env: Vec<String>,
68    /// Working directory for the command.
69    #[serde(default)]
70    pub working_dir: Option<String>,
71    /// Optional guest-visible rootfs path to chroot into before executing.
72    #[serde(default)]
73    pub rootfs: Option<String>,
74    /// Optional stdin data to pipe to the command.
75    #[serde(default)]
76    pub stdin: Option<Vec<u8>>,
77    /// Keep stdin open for subsequent streaming data frames.
78    #[serde(default)]
79    pub stdin_streaming: bool,
80    /// User to run the command as (supported: "root", "1000", "1000:1000").
81    #[serde(default)]
82    pub user: Option<String>,
83    /// Enable streaming mode (receive output chunks as they arrive).
84    #[serde(default)]
85    pub streaming: bool,
86}
87
88/// Output from an executed command.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct ExecOutput {
91    /// Captured stdout bytes.
92    pub stdout: Vec<u8>,
93    /// Captured stderr bytes.
94    pub stderr: Vec<u8>,
95    /// Process exit code.
96    pub exit_code: i32,
97    /// Whether either captured stream exceeded its bound and was truncated.
98    /// Defaults to `false` when reading responses from older guest binaries.
99    #[serde(default)]
100    pub truncated: bool,
101}
102
103/// Which output stream a chunk belongs to.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105pub enum StreamType {
106    /// Standard output.
107    Stdout,
108    /// Standard error.
109    Stderr,
110}
111
112impl std::fmt::Display for StreamType {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        match self {
115            StreamType::Stdout => write!(f, "stdout"),
116            StreamType::Stderr => write!(f, "stderr"),
117        }
118    }
119}
120
121/// A chunk of streaming output from a running command.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ExecChunk {
124    /// Which stream this chunk belongs to.
125    pub stream: StreamType,
126    /// Raw output bytes.
127    pub data: Vec<u8>,
128}
129
130/// Final exit notification from a streaming exec.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct ExecExit {
133    /// Process exit code.
134    pub exit_code: i32,
135    /// Set when the process (or its memory cgroup) was killed by the
136    /// out-of-memory killer. Carried back so the CRI can report the container
137    /// exit reason as `OOMKilled`. Defaults to `false` for wire compatibility.
138    #[serde(default)]
139    pub oom_killed: bool,
140}
141
142/// A streaming exec event — a chunk of output, a flush acknowledgement, or the
143/// final exit.
144#[derive(Debug, Clone)]
145pub enum ExecEvent {
146    /// A chunk of stdout or stderr data.
147    Chunk(ExecChunk),
148    /// Acknowledgement of a flush request: every output chunk the guest had
149    /// buffered when it received the flush has been sent ahead of this marker.
150    /// Used to establish a definitive pre/post boundary for log rotation
151    /// (`ReopenContainerLog`) without racing in-flight output.
152    FlushAck,
153    /// The command has exited.
154    Exit(ExecExit),
155}
156
157/// Metrics collected during command execution.
158#[derive(Debug, Clone, Default, Serialize, Deserialize)]
159pub struct ExecMetrics {
160    /// Wall-clock duration in milliseconds.
161    pub duration_ms: u64,
162    /// Peak memory usage in bytes (if available).
163    #[serde(default)]
164    pub peak_memory_bytes: Option<u64>,
165    /// Total stdout bytes produced.
166    pub stdout_bytes: u64,
167    /// Total stderr bytes produced.
168    pub stderr_bytes: u64,
169}
170
171/// File transfer request for upload/download between host and guest.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct FileRequest {
174    /// Operation type.
175    pub op: FileOp,
176    /// Path inside the guest.
177    pub guest_path: String,
178    /// File content (for upload only, base64-encoded).
179    #[serde(default)]
180    pub data: Option<String>,
181    /// User that owns newly created files and parent directories.
182    #[serde(default)]
183    pub user: Option<String>,
184}
185
186/// File transfer operation type.
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
188pub enum FileOp {
189    /// Upload a file from host to guest.
190    Upload,
191    /// Download a file from guest to host.
192    Download,
193}
194
195/// File transfer response.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct FileResponse {
198    /// Whether the operation succeeded.
199    pub success: bool,
200    /// File content (for download only, base64-encoded).
201    #[serde(default)]
202    pub data: Option<String>,
203    /// File size in bytes.
204    #[serde(default)]
205    pub size: u64,
206    /// Error message if the operation failed.
207    #[serde(default)]
208    pub error: Option<String>,
209}
210
211/// Metadata operation performed inside a managed workload filesystem.
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
213pub enum FilesystemOp {
214    /// Inspect one path without modifying it.
215    Stat,
216    /// Recursively create one directory.
217    MakeDir,
218    /// Rename one entry, creating destination parents when necessary.
219    Move,
220    /// List descendants to a bounded depth.
221    ListDir,
222    /// Recursively remove one entry.
223    Remove,
224}
225
226/// Generation-fenced filesystem request sent to the workload guest.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct FilesystemRequest {
229    /// Requested operation.
230    pub op: FilesystemOp,
231    /// Source or primary path.
232    pub path: String,
233    /// Destination path for [`FilesystemOp::Move`].
234    #[serde(default)]
235    pub destination: Option<String>,
236    /// Requested descendant depth for [`FilesystemOp::ListDir`].
237    #[serde(default)]
238    pub depth: u32,
239    /// Guest user used for home expansion and ownership.
240    #[serde(default)]
241    pub user: Option<String>,
242}
243
244/// Entry type returned by a workload filesystem operation.
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
246pub enum FilesystemEntryKind {
247    /// Entry type could not be represented by the pinned contract.
248    Unspecified,
249    /// Regular file, or a symlink whose target is a regular file.
250    File,
251    /// Directory, or a symlink whose target is a directory.
252    Directory,
253}
254
255/// Portable guest metadata used by compatibility protocol adapters.
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
257pub struct FilesystemEntry {
258    pub name: String,
259    pub kind: FilesystemEntryKind,
260    pub path: String,
261    pub size: i64,
262    pub mode: u32,
263    pub permissions: String,
264    pub owner: String,
265    pub group: String,
266    pub modified_seconds: i64,
267    pub modified_nanos: i32,
268    #[serde(default)]
269    pub symlink_target: Option<String>,
270    #[serde(default)]
271    pub metadata: BTreeMap<String, String>,
272}
273
274/// Result of one workload filesystem metadata or mutation operation.
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct FilesystemResponse {
277    pub success: bool,
278    #[serde(default)]
279    pub entry: Option<FilesystemEntry>,
280    #[serde(default)]
281    pub entries: Vec<FilesystemEntry>,
282    #[serde(default)]
283    pub error: Option<String>,
284}
285
286/// Versioned non-exec request sent over the guest execution session.
287///
288/// Exec requests predate this envelope and remain bare JSON for wire
289/// compatibility. File requests use an explicit discriminator so the guest
290/// never attempts to deserialize them as commands.
291#[derive(Debug, Clone, Serialize, Deserialize)]
292#[serde(tag = "request_type", content = "request", rename_all = "snake_case")]
293pub enum GuestSessionRequest {
294    /// Upload or download one file.
295    File(FileRequest),
296    /// Inspect or mutate workload filesystem metadata.
297    Filesystem(FilesystemRequest),
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn test_exec_request_serialization_roundtrip() {
306        let req = ExecRequest {
307            request_id: Some("exec-1".to_string()),
308            cmd: vec!["ls".to_string(), "-la".to_string()],
309            timeout_ns: 3_000_000_000,
310            env: vec!["FOO=bar".to_string()],
311            working_dir: Some("/tmp".to_string()),
312            rootfs: Some("/run/a3s/cri/rootfs/sb/c/rootfs".to_string()),
313            stdin: None,
314            stdin_streaming: false,
315            user: None,
316            streaming: false,
317        };
318        let json = serde_json::to_string(&req).unwrap();
319        let parsed: ExecRequest = serde_json::from_str(&json).unwrap();
320        assert_eq!(parsed.cmd, vec!["ls", "-la"]);
321        assert_eq!(parsed.request_id.as_deref(), Some("exec-1"));
322        assert_eq!(parsed.timeout_ns, 3_000_000_000);
323        assert_eq!(parsed.env, vec!["FOO=bar"]);
324        assert_eq!(parsed.working_dir, Some("/tmp".to_string()));
325        assert_eq!(
326            parsed.rootfs,
327            Some("/run/a3s/cri/rootfs/sb/c/rootfs".to_string())
328        );
329        assert!(parsed.stdin.is_none());
330        assert!(!parsed.stdin_streaming);
331        assert!(parsed.user.is_none());
332        assert!(!parsed.streaming);
333    }
334
335    #[test]
336    fn test_exec_request_streaming_flag() {
337        let req = ExecRequest {
338            request_id: None,
339            cmd: vec!["tail".to_string(), "-f".to_string()],
340            timeout_ns: 0,
341            env: vec![],
342            working_dir: None,
343            rootfs: None,
344            stdin: None,
345            stdin_streaming: false,
346            user: None,
347            streaming: true,
348        };
349        let json = serde_json::to_string(&req).unwrap();
350        let parsed: ExecRequest = serde_json::from_str(&json).unwrap();
351        assert!(parsed.streaming);
352        assert!(!parsed.stdin_streaming);
353    }
354
355    #[test]
356    fn test_exec_request_stdin_streaming_flag() {
357        let req = ExecRequest {
358            request_id: None,
359            cmd: vec!["cat".to_string()],
360            timeout_ns: 0,
361            env: vec![],
362            working_dir: None,
363            rootfs: None,
364            stdin: None,
365            stdin_streaming: true,
366            user: None,
367            streaming: true,
368        };
369        let json = serde_json::to_string(&req).unwrap();
370        let parsed: ExecRequest = serde_json::from_str(&json).unwrap();
371        assert!(parsed.stdin_streaming);
372    }
373
374    #[test]
375    fn test_exec_output_serialization_roundtrip() {
376        let output = ExecOutput {
377            stdout: b"hello\n".to_vec(),
378            stderr: b"warning\n".to_vec(),
379            exit_code: 0,
380            truncated: true,
381        };
382        let json = serde_json::to_string(&output).unwrap();
383        let parsed: ExecOutput = serde_json::from_str(&json).unwrap();
384        assert_eq!(parsed.stdout, b"hello\n");
385        assert_eq!(parsed.stderr, b"warning\n");
386        assert_eq!(parsed.exit_code, 0);
387        assert!(parsed.truncated);
388    }
389
390    #[test]
391    fn test_exec_output_non_zero_exit() {
392        let output = ExecOutput {
393            stdout: vec![],
394            stderr: b"not found\n".to_vec(),
395            exit_code: 127,
396            truncated: false,
397        };
398        let json = serde_json::to_string(&output).unwrap();
399        let parsed: ExecOutput = serde_json::from_str(&json).unwrap();
400        assert_eq!(parsed.exit_code, 127);
401        assert!(parsed.stdout.is_empty());
402    }
403
404    #[test]
405    fn test_default_timeout_constant() {
406        assert_eq!(DEFAULT_EXEC_TIMEOUT_NS, 5_000_000_000);
407    }
408
409    #[test]
410    fn test_max_output_bytes_constant() {
411        assert_eq!(MAX_OUTPUT_BYTES, 16 * 1024 * 1024);
412        assert_eq!(MAX_ONE_SHOT_OUTPUT_BYTES, 1024 * 1024);
413    }
414
415    #[test]
416    fn maximum_one_shot_output_fits_one_transport_frame() {
417        let output = ExecOutput {
418            stdout: vec![u8::MAX; MAX_ONE_SHOT_OUTPUT_BYTES],
419            stderr: vec![u8::MAX; MAX_ONE_SHOT_OUTPUT_BYTES],
420            exit_code: i32::MIN,
421            truncated: true,
422        };
423        let encoded = serde_json::to_vec(&output).unwrap();
424        assert!(encoded.len() <= a3s_transport::MAX_PAYLOAD_SIZE as usize);
425    }
426
427    #[test]
428    fn test_exec_request_empty_cmd() {
429        let req = ExecRequest {
430            request_id: None,
431            cmd: vec![],
432            timeout_ns: 0,
433            env: vec![],
434            working_dir: None,
435            rootfs: None,
436            stdin: None,
437            stdin_streaming: false,
438            user: None,
439            streaming: false,
440        };
441        let json = serde_json::to_string(&req).unwrap();
442        let parsed: ExecRequest = serde_json::from_str(&json).unwrap();
443        assert!(parsed.cmd.is_empty());
444        assert!(parsed.request_id.is_none());
445        assert_eq!(parsed.timeout_ns, 0);
446        assert!(parsed.env.is_empty());
447        assert!(parsed.working_dir.is_none());
448        assert!(parsed.rootfs.is_none());
449        assert!(!parsed.stdin_streaming);
450        assert!(parsed.user.is_none());
451    }
452
453    #[test]
454    fn test_exec_request_backward_compatible_deserialization() {
455        // Old format without rootfs or streaming fields should still parse.
456        let json = r#"{"cmd":["ls"],"timeout_ns":0}"#;
457        let parsed: ExecRequest = serde_json::from_str(json).unwrap();
458        assert_eq!(parsed.cmd, vec!["ls"]);
459        assert!(parsed.request_id.is_none());
460        assert!(parsed.env.is_empty());
461        assert!(parsed.working_dir.is_none());
462        assert!(parsed.rootfs.is_none());
463        assert!(parsed.stdin.is_none());
464        assert!(!parsed.stdin_streaming);
465        assert!(parsed.user.is_none());
466        assert!(!parsed.streaming);
467    }
468
469    #[test]
470    fn test_exec_request_with_stdin() {
471        let req = ExecRequest {
472            request_id: None,
473            cmd: vec!["sh".to_string()],
474            timeout_ns: 0,
475            env: vec![],
476            working_dir: None,
477            rootfs: None,
478            stdin: Some(b"echo hello\n".to_vec()),
479            stdin_streaming: false,
480            user: None,
481            streaming: false,
482        };
483        let json = serde_json::to_string(&req).unwrap();
484        let parsed: ExecRequest = serde_json::from_str(&json).unwrap();
485        assert_eq!(parsed.stdin, Some(b"echo hello\n".to_vec()));
486        assert!(!parsed.stdin_streaming);
487    }
488
489    #[test]
490    fn test_exec_request_with_user() {
491        let req = ExecRequest {
492            request_id: None,
493            cmd: vec!["whoami".to_string()],
494            timeout_ns: 0,
495            env: vec![],
496            working_dir: None,
497            rootfs: None,
498            stdin: None,
499            stdin_streaming: false,
500            user: Some("root".to_string()),
501            streaming: false,
502        };
503        let json = serde_json::to_string(&req).unwrap();
504        let parsed: ExecRequest = serde_json::from_str(&json).unwrap();
505        assert_eq!(parsed.user, Some("root".to_string()));
506    }
507
508    #[test]
509    fn test_exec_request_with_user_uid_gid() {
510        let req = ExecRequest {
511            request_id: None,
512            cmd: vec!["id".to_string()],
513            timeout_ns: 0,
514            env: vec![],
515            working_dir: None,
516            rootfs: None,
517            stdin: None,
518            stdin_streaming: false,
519            user: Some("1000:1000".to_string()),
520            streaming: false,
521        };
522        let json = serde_json::to_string(&req).unwrap();
523        let parsed: ExecRequest = serde_json::from_str(&json).unwrap();
524        assert_eq!(parsed.user, Some("1000:1000".to_string()));
525    }
526
527    #[test]
528    fn test_exec_output_empty() {
529        let output = ExecOutput {
530            stdout: vec![],
531            stderr: vec![],
532            exit_code: 0,
533            truncated: false,
534        };
535        assert!(output.stdout.is_empty());
536        assert!(output.stderr.is_empty());
537        assert_eq!(output.exit_code, 0);
538        assert!(!output.truncated);
539    }
540
541    #[test]
542    fn test_exec_output_backward_compatible_deserialization() {
543        let parsed: ExecOutput =
544            serde_json::from_str(r#"{"stdout":[],"stderr":[],"exit_code":0}"#).unwrap();
545        assert!(!parsed.truncated);
546    }
547
548    // --- Streaming types ---
549
550    #[test]
551    fn test_stream_type_display() {
552        assert_eq!(StreamType::Stdout.to_string(), "stdout");
553        assert_eq!(StreamType::Stderr.to_string(), "stderr");
554    }
555
556    #[test]
557    fn test_exec_chunk_serde_roundtrip() {
558        let chunk = ExecChunk {
559            stream: StreamType::Stdout,
560            data: b"hello world\n".to_vec(),
561        };
562        let json = serde_json::to_string(&chunk).unwrap();
563        let parsed: ExecChunk = serde_json::from_str(&json).unwrap();
564        assert_eq!(parsed.stream, StreamType::Stdout);
565        assert_eq!(parsed.data, b"hello world\n");
566    }
567
568    #[test]
569    fn test_exec_chunk_stderr() {
570        let chunk = ExecChunk {
571            stream: StreamType::Stderr,
572            data: b"error: not found\n".to_vec(),
573        };
574        let json = serde_json::to_string(&chunk).unwrap();
575        let parsed: ExecChunk = serde_json::from_str(&json).unwrap();
576        assert_eq!(parsed.stream, StreamType::Stderr);
577    }
578
579    #[test]
580    fn test_exec_exit_serde_roundtrip() {
581        let exit = ExecExit {
582            exit_code: 42,
583            oom_killed: false,
584        };
585        let json = serde_json::to_string(&exit).unwrap();
586        let parsed: ExecExit = serde_json::from_str(&json).unwrap();
587        assert_eq!(parsed.exit_code, 42);
588    }
589
590    #[test]
591    fn test_exec_metrics_default() {
592        let m = ExecMetrics::default();
593        assert_eq!(m.duration_ms, 0);
594        assert!(m.peak_memory_bytes.is_none());
595        assert_eq!(m.stdout_bytes, 0);
596        assert_eq!(m.stderr_bytes, 0);
597    }
598
599    #[test]
600    fn test_exec_metrics_serde_roundtrip() {
601        let m = ExecMetrics {
602            duration_ms: 1234,
603            peak_memory_bytes: Some(65536),
604            stdout_bytes: 100,
605            stderr_bytes: 50,
606        };
607        let json = serde_json::to_string(&m).unwrap();
608        let parsed: ExecMetrics = serde_json::from_str(&json).unwrap();
609        assert_eq!(parsed.duration_ms, 1234);
610        assert_eq!(parsed.peak_memory_bytes, Some(65536));
611        assert_eq!(parsed.stdout_bytes, 100);
612        assert_eq!(parsed.stderr_bytes, 50);
613    }
614
615    // --- File transfer types ---
616
617    #[test]
618    fn test_file_request_upload() {
619        let req = FileRequest {
620            op: FileOp::Upload,
621            guest_path: "/tmp/test.txt".to_string(),
622            data: Some("aGVsbG8=".to_string()),
623            user: Some("1000:1000".to_string()),
624        };
625        let json = serde_json::to_string(&req).unwrap();
626        let parsed: FileRequest = serde_json::from_str(&json).unwrap();
627        assert_eq!(parsed.op, FileOp::Upload);
628        assert_eq!(parsed.guest_path, "/tmp/test.txt");
629        assert_eq!(parsed.data.as_deref(), Some("aGVsbG8="));
630    }
631
632    #[test]
633    fn test_file_request_download() {
634        let req = FileRequest {
635            op: FileOp::Download,
636            guest_path: "/etc/hostname".to_string(),
637            data: None,
638            user: None,
639        };
640        let json = serde_json::to_string(&req).unwrap();
641        let parsed: FileRequest = serde_json::from_str(&json).unwrap();
642        assert_eq!(parsed.op, FileOp::Download);
643        assert!(parsed.data.is_none());
644    }
645
646    #[test]
647    fn test_file_response_success() {
648        let resp = FileResponse {
649            success: true,
650            data: Some("Y29udGVudA==".to_string()),
651            size: 7,
652            error: None,
653        };
654        let json = serde_json::to_string(&resp).unwrap();
655        let parsed: FileResponse = serde_json::from_str(&json).unwrap();
656        assert!(parsed.success);
657        assert_eq!(parsed.size, 7);
658        assert!(parsed.error.is_none());
659    }
660
661    #[test]
662    fn test_file_response_error() {
663        let resp = FileResponse {
664            success: false,
665            data: None,
666            size: 0,
667            error: Some("file not found".to_string()),
668        };
669        let json = serde_json::to_string(&resp).unwrap();
670        let parsed: FileResponse = serde_json::from_str(&json).unwrap();
671        assert!(!parsed.success);
672        assert_eq!(parsed.error.as_deref(), Some("file not found"));
673    }
674
675    #[test]
676    fn file_session_request_has_an_unambiguous_wire_discriminator() {
677        let request = GuestSessionRequest::File(FileRequest {
678            op: FileOp::Download,
679            guest_path: "/tmp/data.bin".to_string(),
680            data: None,
681            user: None,
682        });
683
684        let value = serde_json::to_value(&request).unwrap();
685        assert_eq!(value["request_type"], "file");
686        assert_eq!(value["request"]["op"], "Download");
687        assert_eq!(value["request"]["guest_path"], "/tmp/data.bin");
688        assert!(serde_json::from_value::<GuestSessionRequest>(value).is_ok());
689    }
690
691    #[test]
692    fn filesystem_session_request_has_an_unambiguous_wire_discriminator() {
693        let request = GuestSessionRequest::Filesystem(FilesystemRequest {
694            op: FilesystemOp::Move,
695            path: "~/before".to_string(),
696            destination: Some("~/after".to_string()),
697            depth: 0,
698            user: Some("user".to_string()),
699        });
700
701        let value = serde_json::to_value(&request).unwrap();
702        assert_eq!(value["request_type"], "filesystem");
703        assert_eq!(value["request"]["op"], "Move");
704        assert_eq!(value["request"]["destination"], "~/after");
705        assert!(serde_json::from_value::<GuestSessionRequest>(value).is_ok());
706    }
707
708    #[test]
709    fn test_frame_exec_constants() {
710        assert_eq!(FRAME_EXEC_CHUNK, 0x01);
711        assert_eq!(FRAME_EXEC_EXIT, 0x02);
712    }
713}