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 serde::{Deserialize, Serialize};
7
8/// Vsock port for the exec server.
9pub const EXEC_VSOCK_PORT: u32 = a3s_transport::ports::EXEC_SERVER;
10
11/// Vsock port for the Windows host-port forward control channel.
12pub const PORT_FWD_VSOCK_PORT: u32 = 4093;
13
14/// Default exec timeout: 5 seconds.
15pub const DEFAULT_EXEC_TIMEOUT_NS: u64 = 5_000_000_000;
16
17/// Maximum output size per stream (stdout/stderr): 16 MiB.
18pub const MAX_OUTPUT_BYTES: usize = 16 * 1024 * 1024;
19
20/// Frame type byte for streaming exec chunks.
21pub const FRAME_EXEC_CHUNK: u8 = 0x01;
22
23/// Frame type byte for streaming exec exit.
24pub const FRAME_EXEC_EXIT: u8 = 0x02;
25
26/// Request to execute a command in the guest.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ExecRequest {
29    /// Command and arguments (e.g., ["ls", "-la"]).
30    pub cmd: Vec<String>,
31    /// Timeout in nanoseconds. 0 means use the default.
32    pub timeout_ns: u64,
33    /// Additional environment variables (KEY=VALUE pairs).
34    #[serde(default)]
35    pub env: Vec<String>,
36    /// Working directory for the command.
37    #[serde(default)]
38    pub working_dir: Option<String>,
39    /// Optional guest-visible rootfs path to chroot into before executing.
40    #[serde(default)]
41    pub rootfs: Option<String>,
42    /// Optional stdin data to pipe to the command.
43    #[serde(default)]
44    pub stdin: Option<Vec<u8>>,
45    /// Keep stdin open for subsequent streaming data frames.
46    #[serde(default)]
47    pub stdin_streaming: bool,
48    /// User to run the command as (supported: "root", "1000", "1000:1000").
49    #[serde(default)]
50    pub user: Option<String>,
51    /// Enable streaming mode (receive output chunks as they arrive).
52    #[serde(default)]
53    pub streaming: bool,
54}
55
56/// Output from an executed command.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ExecOutput {
59    /// Captured stdout bytes.
60    pub stdout: Vec<u8>,
61    /// Captured stderr bytes.
62    pub stderr: Vec<u8>,
63    /// Process exit code.
64    pub exit_code: i32,
65}
66
67/// Which output stream a chunk belongs to.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69pub enum StreamType {
70    /// Standard output.
71    Stdout,
72    /// Standard error.
73    Stderr,
74}
75
76impl std::fmt::Display for StreamType {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            StreamType::Stdout => write!(f, "stdout"),
80            StreamType::Stderr => write!(f, "stderr"),
81        }
82    }
83}
84
85/// A chunk of streaming output from a running command.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct ExecChunk {
88    /// Which stream this chunk belongs to.
89    pub stream: StreamType,
90    /// Raw output bytes.
91    pub data: Vec<u8>,
92}
93
94/// Final exit notification from a streaming exec.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ExecExit {
97    /// Process exit code.
98    pub exit_code: i32,
99    /// Set when the process (or its memory cgroup) was killed by the
100    /// out-of-memory killer. Carried back so the CRI can report the container
101    /// exit reason as `OOMKilled`. Defaults to `false` for wire compatibility.
102    #[serde(default)]
103    pub oom_killed: bool,
104}
105
106/// A streaming exec event — a chunk of output, a flush acknowledgement, or the
107/// final exit.
108#[derive(Debug, Clone)]
109pub enum ExecEvent {
110    /// A chunk of stdout or stderr data.
111    Chunk(ExecChunk),
112    /// Acknowledgement of a flush request: every output chunk the guest had
113    /// buffered when it received the flush has been sent ahead of this marker.
114    /// Used to establish a definitive pre/post boundary for log rotation
115    /// (`ReopenContainerLog`) without racing in-flight output.
116    FlushAck,
117    /// The command has exited.
118    Exit(ExecExit),
119}
120
121/// Metrics collected during command execution.
122#[derive(Debug, Clone, Default, Serialize, Deserialize)]
123pub struct ExecMetrics {
124    /// Wall-clock duration in milliseconds.
125    pub duration_ms: u64,
126    /// Peak memory usage in bytes (if available).
127    #[serde(default)]
128    pub peak_memory_bytes: Option<u64>,
129    /// Total stdout bytes produced.
130    pub stdout_bytes: u64,
131    /// Total stderr bytes produced.
132    pub stderr_bytes: u64,
133}
134
135/// File transfer request for upload/download between host and guest.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct FileRequest {
138    /// Operation type.
139    pub op: FileOp,
140    /// Path inside the guest.
141    pub guest_path: String,
142    /// File content (for upload only, base64-encoded).
143    #[serde(default)]
144    pub data: Option<String>,
145}
146
147/// File transfer operation type.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149pub enum FileOp {
150    /// Upload a file from host to guest.
151    Upload,
152    /// Download a file from guest to host.
153    Download,
154}
155
156/// File transfer response.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct FileResponse {
159    /// Whether the operation succeeded.
160    pub success: bool,
161    /// File content (for download only, base64-encoded).
162    #[serde(default)]
163    pub data: Option<String>,
164    /// File size in bytes.
165    #[serde(default)]
166    pub size: u64,
167    /// Error message if the operation failed.
168    #[serde(default)]
169    pub error: Option<String>,
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn test_exec_request_serialization_roundtrip() {
178        let req = ExecRequest {
179            cmd: vec!["ls".to_string(), "-la".to_string()],
180            timeout_ns: 3_000_000_000,
181            env: vec!["FOO=bar".to_string()],
182            working_dir: Some("/tmp".to_string()),
183            rootfs: Some("/run/a3s/cri/rootfs/sb/c/rootfs".to_string()),
184            stdin: None,
185            stdin_streaming: false,
186            user: None,
187            streaming: false,
188        };
189        let json = serde_json::to_string(&req).unwrap();
190        let parsed: ExecRequest = serde_json::from_str(&json).unwrap();
191        assert_eq!(parsed.cmd, vec!["ls", "-la"]);
192        assert_eq!(parsed.timeout_ns, 3_000_000_000);
193        assert_eq!(parsed.env, vec!["FOO=bar"]);
194        assert_eq!(parsed.working_dir, Some("/tmp".to_string()));
195        assert_eq!(
196            parsed.rootfs,
197            Some("/run/a3s/cri/rootfs/sb/c/rootfs".to_string())
198        );
199        assert!(parsed.stdin.is_none());
200        assert!(!parsed.stdin_streaming);
201        assert!(parsed.user.is_none());
202        assert!(!parsed.streaming);
203    }
204
205    #[test]
206    fn test_exec_request_streaming_flag() {
207        let req = ExecRequest {
208            cmd: vec!["tail".to_string(), "-f".to_string()],
209            timeout_ns: 0,
210            env: vec![],
211            working_dir: None,
212            rootfs: None,
213            stdin: None,
214            stdin_streaming: false,
215            user: None,
216            streaming: true,
217        };
218        let json = serde_json::to_string(&req).unwrap();
219        let parsed: ExecRequest = serde_json::from_str(&json).unwrap();
220        assert!(parsed.streaming);
221        assert!(!parsed.stdin_streaming);
222    }
223
224    #[test]
225    fn test_exec_request_stdin_streaming_flag() {
226        let req = ExecRequest {
227            cmd: vec!["cat".to_string()],
228            timeout_ns: 0,
229            env: vec![],
230            working_dir: None,
231            rootfs: None,
232            stdin: None,
233            stdin_streaming: true,
234            user: None,
235            streaming: true,
236        };
237        let json = serde_json::to_string(&req).unwrap();
238        let parsed: ExecRequest = serde_json::from_str(&json).unwrap();
239        assert!(parsed.stdin_streaming);
240    }
241
242    #[test]
243    fn test_exec_output_serialization_roundtrip() {
244        let output = ExecOutput {
245            stdout: b"hello\n".to_vec(),
246            stderr: b"warning\n".to_vec(),
247            exit_code: 0,
248        };
249        let json = serde_json::to_string(&output).unwrap();
250        let parsed: ExecOutput = serde_json::from_str(&json).unwrap();
251        assert_eq!(parsed.stdout, b"hello\n");
252        assert_eq!(parsed.stderr, b"warning\n");
253        assert_eq!(parsed.exit_code, 0);
254    }
255
256    #[test]
257    fn test_exec_output_non_zero_exit() {
258        let output = ExecOutput {
259            stdout: vec![],
260            stderr: b"not found\n".to_vec(),
261            exit_code: 127,
262        };
263        let json = serde_json::to_string(&output).unwrap();
264        let parsed: ExecOutput = serde_json::from_str(&json).unwrap();
265        assert_eq!(parsed.exit_code, 127);
266        assert!(parsed.stdout.is_empty());
267    }
268
269    #[test]
270    fn test_default_timeout_constant() {
271        assert_eq!(DEFAULT_EXEC_TIMEOUT_NS, 5_000_000_000);
272    }
273
274    #[test]
275    fn test_max_output_bytes_constant() {
276        assert_eq!(MAX_OUTPUT_BYTES, 16 * 1024 * 1024);
277    }
278
279    #[test]
280    fn test_exec_request_empty_cmd() {
281        let req = ExecRequest {
282            cmd: vec![],
283            timeout_ns: 0,
284            env: vec![],
285            working_dir: None,
286            rootfs: None,
287            stdin: None,
288            stdin_streaming: false,
289            user: None,
290            streaming: false,
291        };
292        let json = serde_json::to_string(&req).unwrap();
293        let parsed: ExecRequest = serde_json::from_str(&json).unwrap();
294        assert!(parsed.cmd.is_empty());
295        assert_eq!(parsed.timeout_ns, 0);
296        assert!(parsed.env.is_empty());
297        assert!(parsed.working_dir.is_none());
298        assert!(parsed.rootfs.is_none());
299        assert!(!parsed.stdin_streaming);
300        assert!(parsed.user.is_none());
301    }
302
303    #[test]
304    fn test_exec_request_backward_compatible_deserialization() {
305        // Old format without rootfs or streaming fields should still parse.
306        let json = r#"{"cmd":["ls"],"timeout_ns":0}"#;
307        let parsed: ExecRequest = serde_json::from_str(json).unwrap();
308        assert_eq!(parsed.cmd, vec!["ls"]);
309        assert!(parsed.env.is_empty());
310        assert!(parsed.working_dir.is_none());
311        assert!(parsed.rootfs.is_none());
312        assert!(parsed.stdin.is_none());
313        assert!(!parsed.stdin_streaming);
314        assert!(parsed.user.is_none());
315        assert!(!parsed.streaming);
316    }
317
318    #[test]
319    fn test_exec_request_with_stdin() {
320        let req = ExecRequest {
321            cmd: vec!["sh".to_string()],
322            timeout_ns: 0,
323            env: vec![],
324            working_dir: None,
325            rootfs: None,
326            stdin: Some(b"echo hello\n".to_vec()),
327            stdin_streaming: false,
328            user: None,
329            streaming: false,
330        };
331        let json = serde_json::to_string(&req).unwrap();
332        let parsed: ExecRequest = serde_json::from_str(&json).unwrap();
333        assert_eq!(parsed.stdin, Some(b"echo hello\n".to_vec()));
334        assert!(!parsed.stdin_streaming);
335    }
336
337    #[test]
338    fn test_exec_request_with_user() {
339        let req = ExecRequest {
340            cmd: vec!["whoami".to_string()],
341            timeout_ns: 0,
342            env: vec![],
343            working_dir: None,
344            rootfs: None,
345            stdin: None,
346            stdin_streaming: false,
347            user: Some("root".to_string()),
348            streaming: false,
349        };
350        let json = serde_json::to_string(&req).unwrap();
351        let parsed: ExecRequest = serde_json::from_str(&json).unwrap();
352        assert_eq!(parsed.user, Some("root".to_string()));
353    }
354
355    #[test]
356    fn test_exec_request_with_user_uid_gid() {
357        let req = ExecRequest {
358            cmd: vec!["id".to_string()],
359            timeout_ns: 0,
360            env: vec![],
361            working_dir: None,
362            rootfs: None,
363            stdin: None,
364            stdin_streaming: false,
365            user: Some("1000:1000".to_string()),
366            streaming: false,
367        };
368        let json = serde_json::to_string(&req).unwrap();
369        let parsed: ExecRequest = serde_json::from_str(&json).unwrap();
370        assert_eq!(parsed.user, Some("1000:1000".to_string()));
371    }
372
373    #[test]
374    fn test_exec_output_empty() {
375        let output = ExecOutput {
376            stdout: vec![],
377            stderr: vec![],
378            exit_code: 0,
379        };
380        assert!(output.stdout.is_empty());
381        assert!(output.stderr.is_empty());
382        assert_eq!(output.exit_code, 0);
383    }
384
385    // --- Streaming types ---
386
387    #[test]
388    fn test_stream_type_display() {
389        assert_eq!(StreamType::Stdout.to_string(), "stdout");
390        assert_eq!(StreamType::Stderr.to_string(), "stderr");
391    }
392
393    #[test]
394    fn test_exec_chunk_serde_roundtrip() {
395        let chunk = ExecChunk {
396            stream: StreamType::Stdout,
397            data: b"hello world\n".to_vec(),
398        };
399        let json = serde_json::to_string(&chunk).unwrap();
400        let parsed: ExecChunk = serde_json::from_str(&json).unwrap();
401        assert_eq!(parsed.stream, StreamType::Stdout);
402        assert_eq!(parsed.data, b"hello world\n");
403    }
404
405    #[test]
406    fn test_exec_chunk_stderr() {
407        let chunk = ExecChunk {
408            stream: StreamType::Stderr,
409            data: b"error: not found\n".to_vec(),
410        };
411        let json = serde_json::to_string(&chunk).unwrap();
412        let parsed: ExecChunk = serde_json::from_str(&json).unwrap();
413        assert_eq!(parsed.stream, StreamType::Stderr);
414    }
415
416    #[test]
417    fn test_exec_exit_serde_roundtrip() {
418        let exit = ExecExit {
419            exit_code: 42,
420            oom_killed: false,
421        };
422        let json = serde_json::to_string(&exit).unwrap();
423        let parsed: ExecExit = serde_json::from_str(&json).unwrap();
424        assert_eq!(parsed.exit_code, 42);
425    }
426
427    #[test]
428    fn test_exec_metrics_default() {
429        let m = ExecMetrics::default();
430        assert_eq!(m.duration_ms, 0);
431        assert!(m.peak_memory_bytes.is_none());
432        assert_eq!(m.stdout_bytes, 0);
433        assert_eq!(m.stderr_bytes, 0);
434    }
435
436    #[test]
437    fn test_exec_metrics_serde_roundtrip() {
438        let m = ExecMetrics {
439            duration_ms: 1234,
440            peak_memory_bytes: Some(65536),
441            stdout_bytes: 100,
442            stderr_bytes: 50,
443        };
444        let json = serde_json::to_string(&m).unwrap();
445        let parsed: ExecMetrics = serde_json::from_str(&json).unwrap();
446        assert_eq!(parsed.duration_ms, 1234);
447        assert_eq!(parsed.peak_memory_bytes, Some(65536));
448        assert_eq!(parsed.stdout_bytes, 100);
449        assert_eq!(parsed.stderr_bytes, 50);
450    }
451
452    // --- File transfer types ---
453
454    #[test]
455    fn test_file_request_upload() {
456        let req = FileRequest {
457            op: FileOp::Upload,
458            guest_path: "/tmp/test.txt".to_string(),
459            data: Some("aGVsbG8=".to_string()),
460        };
461        let json = serde_json::to_string(&req).unwrap();
462        let parsed: FileRequest = serde_json::from_str(&json).unwrap();
463        assert_eq!(parsed.op, FileOp::Upload);
464        assert_eq!(parsed.guest_path, "/tmp/test.txt");
465        assert_eq!(parsed.data.as_deref(), Some("aGVsbG8="));
466    }
467
468    #[test]
469    fn test_file_request_download() {
470        let req = FileRequest {
471            op: FileOp::Download,
472            guest_path: "/etc/hostname".to_string(),
473            data: None,
474        };
475        let json = serde_json::to_string(&req).unwrap();
476        let parsed: FileRequest = serde_json::from_str(&json).unwrap();
477        assert_eq!(parsed.op, FileOp::Download);
478        assert!(parsed.data.is_none());
479    }
480
481    #[test]
482    fn test_file_response_success() {
483        let resp = FileResponse {
484            success: true,
485            data: Some("Y29udGVudA==".to_string()),
486            size: 7,
487            error: None,
488        };
489        let json = serde_json::to_string(&resp).unwrap();
490        let parsed: FileResponse = serde_json::from_str(&json).unwrap();
491        assert!(parsed.success);
492        assert_eq!(parsed.size, 7);
493        assert!(parsed.error.is_none());
494    }
495
496    #[test]
497    fn test_file_response_error() {
498        let resp = FileResponse {
499            success: false,
500            data: None,
501            size: 0,
502            error: Some("file not found".to_string()),
503        };
504        let json = serde_json::to_string(&resp).unwrap();
505        let parsed: FileResponse = serde_json::from_str(&json).unwrap();
506        assert!(!parsed.success);
507        assert_eq!(parsed.error.as_deref(), Some("file not found"));
508    }
509
510    #[test]
511    fn test_frame_exec_constants() {
512        assert_eq!(FRAME_EXEC_CHUNK, 0x01);
513        assert_eq!(FRAME_EXEC_EXIT, 0x02);
514    }
515}