1use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9
10pub const EXEC_VSOCK_PORT: u32 = a3s_transport::ports::EXEC_SERVER;
12
13pub const PORT_FWD_VSOCK_PORT: u32 = 4093;
15
16pub const WINDOWS_CONTROL_SIGNAL_FRAME: u8 = 5;
22
23pub const WINDOWS_CONTROL_EXEC_FRAME: u8 = 6;
29
30pub const WINDOWS_STOP_REQUEST_FILE: &str = "stop.signal";
32
33pub const WINDOWS_STOP_REQUEST_TEMP_FILE: &str = "stop.signal.tmp";
35
36pub const WINDOWS_GUEST_CONTROL_READY_FILE: &str = "guest-control.ready";
38
39pub fn windows_exec_pipe_name(box_id: &str) -> String {
41 format!("a3s-box-exec-{}", box_id.replace('-', ""))
42}
43
44pub fn windows_exec_pipe_path(box_id: &str) -> String {
46 format!(r"\\.\pipe\{}", windows_exec_pipe_name(box_id))
47}
48
49pub const DEFAULT_EXEC_TIMEOUT_NS: u64 = 5_000_000_000;
51
52pub const MAX_OUTPUT_BYTES: usize = 16 * 1024 * 1024;
54
55pub const MAX_ONE_SHOT_OUTPUT_BYTES: usize = 1024 * 1024;
62
63pub const FRAME_EXEC_CHUNK: u8 = 0x01;
65
66pub const FRAME_EXEC_EXIT: u8 = 0x02;
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct ExecRequest {
72 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub request_id: Option<String>,
81 pub cmd: Vec<String>,
83 pub timeout_ns: u64,
85 #[serde(default)]
87 pub env: Vec<String>,
88 #[serde(default)]
90 pub working_dir: Option<String>,
91 #[serde(default)]
93 pub rootfs: Option<String>,
94 #[serde(default)]
96 pub stdin: Option<Vec<u8>>,
97 #[serde(default)]
99 pub stdin_streaming: bool,
100 #[serde(default)]
102 pub user: Option<String>,
103 #[serde(default)]
105 pub streaming: bool,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct ExecOutput {
111 pub stdout: Vec<u8>,
113 pub stderr: Vec<u8>,
115 pub exit_code: i32,
117 #[serde(default)]
120 pub truncated: bool,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
125pub enum StreamType {
126 Stdout,
128 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#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct ExecChunk {
144 pub stream: StreamType,
146 pub data: Vec<u8>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct ExecExit {
153 pub exit_code: i32,
155 #[serde(default)]
159 pub oom_killed: bool,
160}
161
162#[derive(Debug, Clone)]
165pub enum ExecEvent {
166 Chunk(ExecChunk),
168 FlushAck,
173 Exit(ExecExit),
175}
176
177#[derive(Debug, Clone, Default, Serialize, Deserialize)]
179pub struct ExecMetrics {
180 pub duration_ms: u64,
182 #[serde(default)]
184 pub peak_memory_bytes: Option<u64>,
185 pub stdout_bytes: u64,
187 pub stderr_bytes: u64,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct FileRequest {
194 pub op: FileOp,
196 pub guest_path: String,
198 #[serde(default)]
200 pub data: Option<String>,
201 #[serde(default)]
203 pub user: Option<String>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub max_bytes: Option<u64>,
210}
211
212pub const MAX_BOUNDED_FILE_BYTES: u64 = a3s_transport::MAX_PAYLOAD_SIZE as u64 / 2;
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
220pub enum FileOp {
221 Upload,
223 Download,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct FileResponse {
230 pub success: bool,
232 #[serde(default)]
234 pub data: Option<String>,
235 #[serde(default)]
237 pub size: u64,
238 #[serde(default)]
240 pub error: Option<String>,
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
245pub enum FilesystemOp {
246 Stat,
248 MakeDir,
250 Move,
252 ListDir,
254 Remove,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct FilesystemRequest {
261 pub op: FilesystemOp,
263 pub path: String,
265 #[serde(default)]
267 pub destination: Option<String>,
268 #[serde(default)]
270 pub depth: u32,
271 #[serde(default)]
273 pub user: Option<String>,
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
278pub enum FilesystemEntryKind {
279 Unspecified,
281 File,
283 Directory,
285}
286
287#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
324#[serde(tag = "request_type", content = "request", rename_all = "snake_case")]
325pub enum GuestSessionRequest {
326 File(FileRequest),
328 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 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 #[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 #[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}