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