1#[cfg(target_os = "linux")]
4use std::fs::File;
5use std::fs::OpenOptions;
6#[cfg(target_os = "linux")]
7use std::io::{Read, Seek, SeekFrom};
8use std::path::{Path, PathBuf};
9use std::process::Command;
10#[cfg(target_os = "linux")]
11use std::process::Stdio;
12#[cfg(target_os = "linux")]
13use std::time::{Duration, Instant};
14
15use a3s_box_core::error::{BoxError, Result};
16use a3s_box_core::execution::ResolvedExecutionPlan;
17use a3s_box_core::log::LogConfig;
18#[cfg(target_os = "linux")]
19use a3s_box_core::log::{SandboxLogWorkerSpec, SANDBOX_LOG_WORKER_SCHEMA};
20use oci_spec::runtime::Spec;
21use serde::Serialize;
22
23use super::capability::{CertifiedCrun, SandboxCapabilitySnapshot};
24use super::handler::CrunHandler;
25#[cfg(target_os = "linux")]
26use super::handler::{CrunHandlerSpec, CrunState};
27
28#[cfg(target_os = "linux")]
29const EXEC_LISTENER_FD: i32 = 3;
30#[cfg(target_os = "linux")]
31const PTY_LISTENER_FD: i32 = 4;
32#[cfg(target_os = "linux")]
33const INIT_LOG_FD: i32 = 5;
34#[cfg(target_os = "linux")]
35const PRESERVED_FD_COUNT: usize = 3;
36#[cfg(target_os = "linux")]
37const START_TIMEOUT: Duration = Duration::from_secs(10);
38#[cfg(target_os = "linux")]
39const START_FAILURE_LOG_LIMIT_BYTES: u64 = 4 * 1024;
40
41pub struct SandboxLaunchSpec {
43 pub container_id: String,
44 pub bundle_dir: PathBuf,
45 pub runtime_root: PathBuf,
46 pub runtime_record: PathBuf,
47 pub exec_socket_path: PathBuf,
48 pub pty_socket_path: PathBuf,
49 pub stdout_path: PathBuf,
50 pub stderr_path: PathBuf,
51 pub init_log_path: PathBuf,
52 pub log_config: LogConfig,
53 pub log_worker_path: PathBuf,
54 pub log_worker_log_path: PathBuf,
55 pub log_worker_ready_path: PathBuf,
56}
57
58#[cfg(target_os = "linux")]
59#[derive(Debug, Serialize)]
60struct SandboxRuntimeRecord<'a> {
61 schema: &'static str,
62 container_id: &'a str,
63 runtime_path: &'a Path,
64 runtime_root: &'a Path,
65 bundle_dir: &'a Path,
66 init_pid: u32,
67 log_worker_pid: u32,
68 log_worker_pid_start_time: u64,
69}
70
71pub struct CrunController {
73 runtime: CertifiedCrun,
74}
75
76impl CrunController {
77 pub fn new(runtime: CertifiedCrun) -> Self {
78 Self { runtime }
79 }
80
81 pub fn require_absent(&self, runtime_root: &Path, container_id: &str) -> Result<()> {
83 match std::fs::symlink_metadata(runtime_root) {
84 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
85 Err(error) => {
86 return Err(BoxError::BoxBootError {
87 message: format!(
88 "Failed to inspect Sandbox runtime root {}: {error}",
89 runtime_root.display()
90 ),
91 hint: None,
92 })
93 }
94 Ok(metadata) if !metadata.file_type().is_dir() => {
95 return Err(BoxError::BoxBootError {
96 message: format!(
97 "Sandbox runtime root is not a directory: {}",
98 runtime_root.display()
99 ),
100 hint: None,
101 })
102 }
103 Ok(_) => {}
104 }
105
106 match CrunHandler::query_state_at(&self.runtime.path, runtime_root, container_id)? {
110 Some(state) if state.status == "stopped" => {
111 let output = Command::new(&self.runtime.path)
112 .arg("--root")
113 .arg(runtime_root)
114 .arg("delete")
115 .arg("--force")
116 .arg(container_id)
117 .env("LC_ALL", "C")
118 .output()
119 .map_err(|error| BoxError::BoxBootError {
120 message: format!("Failed to delete stopped Sandbox generation: {error}"),
121 hint: None,
122 })?;
123 if !output.status.success() {
124 return Err(BoxError::BoxBootError {
125 message: format!(
126 "Failed to delete stopped Sandbox generation: {}",
127 String::from_utf8_lossy(&output.stderr).trim()
128 ),
129 hint: None,
130 });
131 }
132 Ok(())
133 }
134 Some(state) => Err(BoxError::BoxBootError {
135 message: format!(
136 "Sandbox runtime ID {container_id} already exists in state {}",
137 state.status
138 ),
139 hint: Some(
140 "Reconcile or stop the existing Sandbox before restarting it".to_string(),
141 ),
142 }),
143 None => Ok(()),
144 }
145 }
146
147 #[cfg(target_os = "linux")]
148 pub async fn start(&self, launch: SandboxLaunchSpec) -> Result<CrunHandler> {
149 use std::os::fd::AsRawFd;
150 use std::os::unix::process::CommandExt;
151
152 self.require_absent(&launch.runtime_root, &launch.container_id)?;
153 create_private_dir(&launch.runtime_root)?;
154 let exec_listener = bind_control_listener(&launch.exec_socket_path)?;
155 let pty_listener = bind_control_listener(&launch.pty_socket_path)?;
156 let stdout = open_log(&launch.stdout_path)?;
157 let stderr = open_log(&launch.stderr_path)?;
158 let init_log = open_log(&launch.init_log_path)?;
159
160 let inherited_exec = duplicate_for_inheritance(exec_listener.as_raw_fd())?;
161 let inherited_pty = duplicate_for_inheritance(pty_listener.as_raw_fd())?;
162 let inherited_log = duplicate_for_inheritance(init_log.as_raw_fd())?;
163 let exec_fd = inherited_exec.as_raw_fd();
164 let pty_fd = inherited_pty.as_raw_fd();
165 let log_fd = inherited_log.as_raw_fd();
166
167 let mut command = Command::new(&self.runtime.path);
168 command
169 .arg("--root")
170 .arg(&launch.runtime_root)
171 .arg("run")
172 .arg("--bundle")
173 .arg(&launch.bundle_dir)
174 .arg("--preserve-fds")
175 .arg(PRESERVED_FD_COUNT.to_string())
176 .arg(&launch.container_id)
177 .env("LC_ALL", "C")
178 .stdin(Stdio::null())
179 .stdout(Stdio::from(stdout))
180 .stderr(Stdio::from(stderr));
181
182 unsafe {
185 command.pre_exec(move || {
186 for (source, destination) in [
187 (exec_fd, EXEC_LISTENER_FD),
188 (pty_fd, PTY_LISTENER_FD),
189 (log_fd, INIT_LOG_FD),
190 ] {
191 if libc::dup2(source, destination) < 0 {
192 return Err(std::io::Error::last_os_error());
193 }
194 }
195 Ok(())
196 });
197 }
198
199 let mut child = command.spawn().map_err(|error| BoxError::BoxBootError {
200 message: format!("Failed to start certified crun runtime: {error}"),
201 hint: None,
202 })?;
203 drop((inherited_exec, inherited_pty, inherited_log));
204 drop((exec_listener, pty_listener, init_log));
207
208 let deadline = Instant::now() + START_TIMEOUT;
209 let init_pid = loop {
210 let child_status = match child.try_wait() {
211 Ok(status) => status,
212 Err(error) => {
213 cleanup_failed_start(&self.runtime.path, &launch);
214 let _ = child.kill();
215 let _ = child.wait();
216 return Err(BoxError::IoError(error));
217 }
218 };
219 if let Some(status) = child_status {
220 let diagnostics = start_failure_diagnostics(&launch);
221 cleanup_failed_start(&self.runtime.path, &launch);
222 return Err(BoxError::BoxBootError {
223 message: format!(
224 "crun run exited before the Sandbox was running: {status}{diagnostics}"
225 ),
226 hint: None,
227 });
228 }
229 let runtime_state = match CrunHandler::query_state_at(
230 &self.runtime.path,
231 &launch.runtime_root,
232 &launch.container_id,
233 ) {
234 Ok(state) => state,
235 Err(error) => {
236 cleanup_failed_start(&self.runtime.path, &launch);
237 let _ = child.kill();
238 let _ = child.wait();
239 return Err(error);
240 }
241 };
242 if let Some(CrunState { status, pid }) = runtime_state {
243 if status == "running" && pid > 0 {
244 break pid;
245 }
246 if status == "stopped" {
247 let diagnostics = start_failure_diagnostics(&launch);
248 cleanup_failed_start(&self.runtime.path, &launch);
249 return Err(BoxError::BoxBootError {
250 message: format!("Sandbox stopped during OCI startup{diagnostics}"),
251 hint: None,
252 });
253 }
254 }
255 if Instant::now() >= deadline {
256 let diagnostics = start_failure_diagnostics(&launch);
257 cleanup_failed_start(&self.runtime.path, &launch);
258 let _ = child.kill();
259 let _ = child.wait();
260 return Err(BoxError::BoxBootError {
261 message: format!(
262 "Timed out waiting for the Sandbox OCI state to become running{diagnostics}"
263 ),
264 hint: None,
265 });
266 }
267 tokio::time::sleep(Duration::from_millis(25)).await;
268 };
269
270 let watched_pid = child.id();
271 let watched_pid_start_time = match crate::process::pid_start_time(watched_pid) {
272 Some(start_time) => start_time,
273 None => {
274 cleanup_failed_start(&self.runtime.path, &launch);
275 let _ = child.kill();
276 let _ = child.wait();
277 return Err(BoxError::BoxBootError {
278 message: "Failed to capture crun wrapper process identity for Sandbox logs"
279 .to_string(),
280 hint: None,
281 });
282 }
283 };
284 let mut log_worker = match start_log_worker(&launch, watched_pid, watched_pid_start_time) {
285 Ok(worker) => worker,
286 Err(error) => {
287 cleanup_failed_start(&self.runtime.path, &launch);
288 let _ = child.kill();
289 let _ = child.wait();
290 return Err(error);
291 }
292 };
293 let log_worker_pid = log_worker.id();
294 let log_worker_pid_start_time = match crate::process::pid_start_time(log_worker_pid) {
295 Some(start_time) => start_time,
296 None => {
297 cleanup_failed_start(&self.runtime.path, &launch);
298 reap_failed_log_worker(&mut log_worker);
299 let _ = child.kill();
300 let _ = child.wait();
301 return Err(BoxError::BoxBootError {
302 message: "Failed to capture Sandbox log worker process identity".to_string(),
303 hint: None,
304 });
305 }
306 };
307
308 let record = SandboxRuntimeRecord {
309 schema: "a3s.box.sandbox-runtime.v1",
310 container_id: &launch.container_id,
311 runtime_path: &self.runtime.path,
312 runtime_root: &launch.runtime_root,
313 bundle_dir: &launch.bundle_dir,
314 init_pid,
315 log_worker_pid,
316 log_worker_pid_start_time,
317 };
318 if let Err(error) = write_json_atomic(&launch.runtime_record, &record) {
319 cleanup_failed_start(&self.runtime.path, &launch);
320 reap_failed_log_worker(&mut log_worker);
321 let _ = child.kill();
322 let _ = child.wait();
323 return Err(error);
324 }
325
326 Ok(CrunHandler::from_child(
327 CrunHandlerSpec::new(
328 self.runtime.path.clone(),
329 launch.runtime_root,
330 launch.container_id,
331 init_pid,
332 launch.bundle_dir,
333 launch.runtime_record,
334 ),
335 child,
336 log_worker,
337 log_worker_pid_start_time,
338 ))
339 }
340
341 #[cfg(not(target_os = "linux"))]
342 pub async fn start(&self, _launch: SandboxLaunchSpec) -> Result<CrunHandler> {
343 Err(BoxError::BoxBootError {
344 message: "Sandbox execution requires Linux".to_string(),
345 hint: Some("Run this workload on an A3S OS Sandbox host".to_string()),
346 })
347 }
348}
349
350pub fn write_bundle(
352 bundle_dir: &Path,
353 spec: &Spec,
354 execution_plan: &ResolvedExecutionPlan,
355 capabilities: &SandboxCapabilitySnapshot,
356) -> Result<()> {
357 create_private_dir(bundle_dir)?;
358 write_json_atomic(&bundle_dir.join("config.json"), spec)?;
359 write_json_atomic(&bundle_dir.join("execution-plan.json"), execution_plan)?;
360 write_json_atomic(&bundle_dir.join("capabilities.json"), capabilities)?;
361 Ok(())
362}
363
364fn write_json_atomic(path: &Path, value: &impl Serialize) -> Result<()> {
365 use std::io::Write;
366 #[cfg(unix)]
367 use std::os::unix::fs::OpenOptionsExt;
368
369 let parent = path.parent().ok_or_else(|| {
370 BoxError::ConfigError(format!(
371 "Sandbox artifact has no parent: {}",
372 path.display()
373 ))
374 })?;
375 create_private_dir(parent)?;
376 let temporary = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
377 let bytes = serde_json::to_vec_pretty(value).map_err(|error| {
378 BoxError::SerializationError(format!("Failed to encode Sandbox artifact: {error}"))
379 })?;
380 let mut options = OpenOptions::new();
381 options.create_new(true).write(true);
382 #[cfg(unix)]
383 options.mode(0o600);
384 let mut file = options.open(&temporary).map_err(BoxError::IoError)?;
385 file.write_all(&bytes).map_err(BoxError::IoError)?;
386 file.write_all(b"\n").map_err(BoxError::IoError)?;
387 file.sync_all().map_err(BoxError::IoError)?;
388 std::fs::rename(&temporary, path).map_err(BoxError::IoError)?;
389 Ok(())
390}
391
392fn create_private_dir(path: &Path) -> Result<()> {
393 std::fs::create_dir_all(path).map_err(BoxError::IoError)?;
394 #[cfg(unix)]
395 {
396 use std::os::unix::fs::PermissionsExt;
397 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
398 .map_err(BoxError::IoError)?;
399 }
400 Ok(())
401}
402
403#[cfg(target_os = "linux")]
404fn open_log(path: &Path) -> Result<File> {
405 #[cfg(unix)]
406 use std::os::unix::fs::OpenOptionsExt;
407
408 let parent = path.parent().ok_or_else(|| {
409 BoxError::ConfigError(format!("Sandbox log has no parent: {}", path.display()))
410 })?;
411 create_private_dir(parent)?;
412 let mut options = OpenOptions::new();
413 options.create(true).truncate(true).write(true);
414 #[cfg(unix)]
415 options.mode(0o600);
416 options.open(path).map_err(BoxError::IoError)
417}
418
419#[cfg(target_os = "linux")]
420fn start_log_worker(
421 launch: &SandboxLaunchSpec,
422 watched_pid: u32,
423 watched_pid_start_time: u64,
424) -> Result<std::process::Child> {
425 let _ = std::fs::remove_file(&launch.log_worker_ready_path);
426 let worker_spec = SandboxLogWorkerSpec {
427 schema: SANDBOX_LOG_WORKER_SCHEMA.to_string(),
428 box_id: launch.container_id.clone(),
429 console_log: launch.stdout_path.clone(),
430 log_config: launch.log_config.clone(),
431 watched_pid,
432 watched_pid_start_time,
433 ready_file: launch.log_worker_ready_path.clone(),
434 };
435 let config = serde_json::to_string(&worker_spec).map_err(|error| {
436 BoxError::SerializationError(format!(
437 "Failed to encode Sandbox log worker configuration: {error}"
438 ))
439 })?;
440 let stdout = open_log(&launch.log_worker_log_path)?;
441 let stderr = stdout.try_clone().map_err(BoxError::IoError)?;
442 let mut worker = Command::new(&launch.log_worker_path)
443 .arg("--sandbox-log-worker-config")
444 .arg(config)
445 .env("LC_ALL", "C")
446 .stdin(Stdio::null())
447 .stdout(Stdio::from(stdout))
448 .stderr(Stdio::from(stderr))
449 .spawn()
450 .map_err(|error| BoxError::BoxBootError {
451 message: format!("Failed to start Sandbox log worker: {error}"),
452 hint: None,
453 })?;
454
455 let deadline = Instant::now() + Duration::from_secs(3);
456 loop {
457 if launch.log_worker_ready_path.is_file() {
458 return Ok(worker);
459 }
460 match worker.try_wait() {
461 Ok(Some(status)) => {
462 let diagnostics =
463 read_log_tail(&launch.log_worker_log_path, START_FAILURE_LOG_LIMIT_BYTES)
464 .map(|excerpt| format!(": {excerpt}"))
465 .unwrap_or_default();
466 return Err(BoxError::BoxBootError {
467 message: format!(
468 "Sandbox log worker exited before readiness with {status}{diagnostics}"
469 ),
470 hint: None,
471 });
472 }
473 Ok(None) => {}
474 Err(error) => return Err(BoxError::IoError(error)),
475 }
476 if Instant::now() >= deadline {
477 reap_failed_log_worker(&mut worker);
478 return Err(BoxError::BoxBootError {
479 message: "Timed out waiting for Sandbox log worker readiness".to_string(),
480 hint: None,
481 });
482 }
483 std::thread::sleep(Duration::from_millis(5));
484 }
485}
486
487#[cfg(target_os = "linux")]
488fn reap_failed_log_worker(worker: &mut std::process::Child) {
489 let deadline = Instant::now() + Duration::from_secs(1);
490 loop {
491 match worker.try_wait() {
492 Ok(Some(_)) => return,
493 Ok(None) if Instant::now() < deadline => {
494 std::thread::sleep(Duration::from_millis(10));
495 }
496 _ => break,
497 }
498 }
499 let _ = worker.kill();
500 let _ = worker.wait();
501}
502
503#[cfg(target_os = "linux")]
504fn bind_control_listener(path: &Path) -> Result<std::os::unix::net::UnixListener> {
505 use std::os::unix::fs::{FileTypeExt, PermissionsExt};
506
507 let parent = path.parent().ok_or_else(|| {
508 BoxError::ConfigError(format!("Sandbox socket has no parent: {}", path.display()))
509 })?;
510 create_private_dir(parent)?;
511 match std::fs::symlink_metadata(path) {
512 Ok(metadata) if metadata.file_type().is_socket() => {
513 std::fs::remove_file(path).map_err(BoxError::IoError)?;
514 }
515 Ok(_) => {
516 return Err(BoxError::BoxBootError {
517 message: format!(
518 "Refusing to replace non-socket Sandbox control path {}",
519 path.display()
520 ),
521 hint: None,
522 })
523 }
524 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
525 Err(error) => return Err(BoxError::IoError(error)),
526 }
527 let listener = std::os::unix::net::UnixListener::bind(path).map_err(BoxError::IoError)?;
528 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
529 .map_err(BoxError::IoError)?;
530 Ok(listener)
531}
532
533#[cfg(target_os = "linux")]
534fn duplicate_for_inheritance(fd: i32) -> Result<std::os::fd::OwnedFd> {
535 use std::os::fd::{FromRawFd, OwnedFd};
536 let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) };
537 if duplicate < 0 {
538 return Err(BoxError::IoError(std::io::Error::last_os_error()));
539 }
540 Ok(unsafe { OwnedFd::from_raw_fd(duplicate) })
542}
543
544#[cfg(target_os = "linux")]
545fn start_failure_diagnostics(launch: &SandboxLaunchSpec) -> String {
546 let diagnostics = [
547 ("crun stderr", &launch.stderr_path),
548 ("guest-init log", &launch.init_log_path),
549 ("Sandbox log worker", &launch.log_worker_log_path),
550 ]
551 .into_iter()
552 .filter_map(|(label, path)| {
553 read_log_tail(path, START_FAILURE_LOG_LIMIT_BYTES)
554 .map(|excerpt| format!("{label}: {excerpt}"))
555 })
556 .collect::<Vec<_>>();
557
558 if diagnostics.is_empty() {
559 String::new()
560 } else {
561 format!(" ({})", diagnostics.join("; "))
562 }
563}
564
565#[cfg(target_os = "linux")]
566fn read_log_tail(path: &Path, limit: u64) -> Option<String> {
567 let mut file = File::open(path).ok()?;
568 let length = file.metadata().ok()?.len();
569 let offset = length.saturating_sub(limit);
570 file.seek(SeekFrom::Start(offset)).ok()?;
571
572 let mut bytes = Vec::with_capacity((length - offset) as usize);
573 file.take(limit).read_to_end(&mut bytes).ok()?;
574 let excerpt = String::from_utf8_lossy(&bytes).trim().to_string();
575 if excerpt.is_empty() {
576 None
577 } else if offset > 0 {
578 Some(format!("...{excerpt}"))
579 } else {
580 Some(excerpt)
581 }
582}
583
584#[cfg(target_os = "linux")]
585fn cleanup_failed_start(runtime_path: &Path, launch: &SandboxLaunchSpec) {
586 let _ = Command::new(runtime_path)
587 .arg("--root")
588 .arg(&launch.runtime_root)
589 .arg("delete")
590 .arg("--force")
591 .arg(&launch.container_id)
592 .env("LC_ALL", "C")
593 .output();
594 let _ = std::fs::remove_file(&launch.runtime_record);
595 let _ = std::fs::remove_dir_all(&launch.runtime_root);
596}
597
598#[cfg(all(test, target_os = "linux"))]
599mod tests {
600 use super::*;
601
602 fn controller_with_runtime(path: PathBuf) -> CrunController {
603 CrunController::new(CertifiedCrun {
604 path,
605 version: "1.28".to_string(),
606 sha256: "test-digest".to_string(),
607 features: vec!["+CAP".to_string(), "+SECCOMP".to_string()],
608 })
609 }
610
611 #[test]
612 fn absent_runtime_root_is_not_materialized_by_state_probe() {
613 let temporary = tempfile::tempdir().unwrap();
614 let runtime_root = temporary.path().join("missing-runtime-root");
615 let controller = controller_with_runtime(temporary.path().join("must-not-run"));
616
617 controller
618 .require_absent(&runtime_root, "internal-execution-id")
619 .unwrap();
620
621 assert!(!runtime_root.exists());
622 }
623
624 #[test]
625 fn runtime_root_symlink_is_rejected_before_executing_crun() {
626 use std::os::unix::fs::symlink;
627
628 let temporary = tempfile::tempdir().unwrap();
629 let target = temporary.path().join("target");
630 let runtime_root = temporary.path().join("runtime-root");
631 std::fs::create_dir(&target).unwrap();
632 symlink(&target, &runtime_root).unwrap();
633 let controller = controller_with_runtime(temporary.path().join("must-not-run"));
634
635 let error = controller
636 .require_absent(&runtime_root, "internal-execution-id")
637 .unwrap_err();
638
639 assert!(error.to_string().contains("not a directory"));
640 assert!(target.read_dir().unwrap().next().is_none());
641 }
642
643 #[test]
644 fn startup_log_excerpt_is_bounded_and_keeps_the_tail() {
645 let temporary = tempfile::tempdir().unwrap();
646 let path = temporary.path().join("crun.stderr.log");
647 let mut contents = "x".repeat(START_FAILURE_LOG_LIMIT_BYTES as usize + 512);
648 contents.push_str("\nseccomp unknown architecture `NATIVE`\n");
649 std::fs::write(&path, contents).unwrap();
650
651 let excerpt = read_log_tail(&path, START_FAILURE_LOG_LIMIT_BYTES).unwrap();
652 assert!(excerpt.starts_with("..."));
653 assert!(excerpt.contains("seccomp unknown architecture `NATIVE`"));
654 assert!(excerpt.len() <= START_FAILURE_LOG_LIMIT_BYTES as usize + 3);
655 }
656}