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