a3s_box_runtime/sandbox/
controller.rs1#[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};
9#[cfg(target_os = "linux")]
10use std::process::{Command, Stdio};
11#[cfg(target_os = "linux")]
12use std::time::{Duration, Instant};
13
14use a3s_box_core::error::{BoxError, Result};
15use a3s_box_core::execution::ResolvedExecutionPlan;
16use a3s_box_core::log::LogConfig;
17#[cfg(target_os = "linux")]
18use a3s_box_core::log::{SandboxLogWorkerSpec, SANDBOX_LOG_WORKER_SCHEMA};
19use oci_spec::runtime::Spec;
20use serde::Serialize;
21
22use super::capability::SandboxCapabilitySnapshot;
23
24#[cfg(target_os = "linux")]
25pub(crate) const EXEC_LISTENER_FD: i32 = 3;
26#[cfg(target_os = "linux")]
27pub(crate) const PTY_LISTENER_FD: i32 = 4;
28#[cfg(target_os = "linux")]
29pub(crate) const INIT_LOG_FD: i32 = 5;
30#[cfg(target_os = "linux")]
31pub(crate) const START_TIMEOUT: Duration = Duration::from_secs(10);
32#[cfg(target_os = "linux")]
33const START_FAILURE_LOG_LIMIT_BYTES: u64 = 4 * 1024;
34
35pub struct SandboxLaunchSpec {
37 pub container_id: String,
38 pub bundle_dir: PathBuf,
39 pub runtime_root: PathBuf,
40 pub runtime_record: PathBuf,
41 pub exec_socket_path: PathBuf,
42 pub pty_socket_path: PathBuf,
43 pub stdout_path: PathBuf,
44 pub stderr_path: PathBuf,
45 pub init_log_path: PathBuf,
46 pub log_config: LogConfig,
47 pub log_worker_path: PathBuf,
48 pub log_worker_log_path: PathBuf,
49 pub log_worker_ready_path: PathBuf,
50}
51
52pub fn write_bundle(
54 bundle_dir: &Path,
55 spec: &Spec,
56 execution_plan: &ResolvedExecutionPlan,
57 capabilities: &SandboxCapabilitySnapshot,
58) -> Result<()> {
59 create_private_dir(bundle_dir)?;
60 write_json_atomic(&bundle_dir.join("config.json"), spec)?;
61 write_json_atomic(&bundle_dir.join("execution-plan.json"), execution_plan)?;
62 write_json_atomic(&bundle_dir.join("capabilities.json"), capabilities)?;
63 Ok(())
64}
65
66pub(crate) fn write_json_atomic(path: &Path, value: &impl Serialize) -> Result<()> {
67 use std::io::Write;
68 #[cfg(unix)]
69 use std::os::unix::fs::OpenOptionsExt;
70
71 let parent = path.parent().ok_or_else(|| {
72 BoxError::ConfigError(format!(
73 "Sandbox artifact has no parent: {}",
74 path.display()
75 ))
76 })?;
77 create_private_dir(parent)?;
78 let temporary = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
79 let bytes = serde_json::to_vec_pretty(value).map_err(|error| {
80 BoxError::SerializationError(format!("Failed to encode Sandbox artifact: {error}"))
81 })?;
82 let mut options = OpenOptions::new();
83 options.create_new(true).write(true);
84 #[cfg(unix)]
85 options.mode(0o600);
86 let mut file = options.open(&temporary).map_err(BoxError::IoError)?;
87 file.write_all(&bytes).map_err(BoxError::IoError)?;
88 file.write_all(b"\n").map_err(BoxError::IoError)?;
89 file.sync_all().map_err(BoxError::IoError)?;
90 std::fs::rename(&temporary, path).map_err(BoxError::IoError)?;
91 Ok(())
92}
93
94pub(crate) fn create_private_dir(path: &Path) -> Result<()> {
95 std::fs::create_dir_all(path).map_err(BoxError::IoError)?;
96 #[cfg(unix)]
97 {
98 use std::os::unix::fs::PermissionsExt;
99 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
100 .map_err(BoxError::IoError)?;
101 }
102 Ok(())
103}
104
105#[cfg(target_os = "linux")]
106pub(crate) fn open_log(path: &Path) -> Result<File> {
107 use std::os::unix::fs::OpenOptionsExt;
108
109 let parent = path.parent().ok_or_else(|| {
110 BoxError::ConfigError(format!("Sandbox log has no parent: {}", path.display()))
111 })?;
112 create_private_dir(parent)?;
113 let mut options = OpenOptions::new();
114 options.create(true).truncate(true).write(true).mode(0o600);
115 options.open(path).map_err(BoxError::IoError)
116}
117
118#[cfg(target_os = "linux")]
119pub(crate) fn start_log_worker(
120 launch: &SandboxLaunchSpec,
121 watched_pid: u32,
122 watched_pid_start_time: u64,
123) -> Result<std::process::Child> {
124 let _ = std::fs::remove_file(&launch.log_worker_ready_path);
125 let worker_spec = SandboxLogWorkerSpec {
126 schema: SANDBOX_LOG_WORKER_SCHEMA.to_string(),
127 box_id: launch.container_id.clone(),
128 console_log: launch.stdout_path.clone(),
129 log_config: launch.log_config.clone(),
130 watched_pid,
131 watched_pid_start_time,
132 ready_file: launch.log_worker_ready_path.clone(),
133 };
134 let config = serde_json::to_string(&worker_spec).map_err(|error| {
135 BoxError::SerializationError(format!(
136 "Failed to encode Sandbox log worker configuration: {error}"
137 ))
138 })?;
139 let stdout = open_log(&launch.log_worker_log_path)?;
140 let stderr = stdout.try_clone().map_err(BoxError::IoError)?;
141 let mut worker = Command::new(&launch.log_worker_path)
142 .arg("--sandbox-log-worker-config")
143 .arg(config)
144 .env("LC_ALL", "C")
145 .stdin(Stdio::null())
146 .stdout(Stdio::from(stdout))
147 .stderr(Stdio::from(stderr))
148 .spawn()
149 .map_err(|error| BoxError::BoxBootError {
150 message: format!("Failed to start Sandbox log worker: {error}"),
151 hint: None,
152 })?;
153
154 let deadline = Instant::now() + Duration::from_secs(3);
155 loop {
156 if launch.log_worker_ready_path.is_file() {
157 return Ok(worker);
158 }
159 match worker.try_wait() {
160 Ok(Some(status)) => {
161 let diagnostics =
162 read_log_tail(&launch.log_worker_log_path, START_FAILURE_LOG_LIMIT_BYTES)
163 .map(|excerpt| format!(": {excerpt}"))
164 .unwrap_or_default();
165 return Err(BoxError::BoxBootError {
166 message: format!(
167 "Sandbox log worker exited before readiness with {status}{diagnostics}"
168 ),
169 hint: None,
170 });
171 }
172 Ok(None) => {}
173 Err(error) => return Err(BoxError::IoError(error)),
174 }
175 if Instant::now() >= deadline {
176 reap_failed_log_worker(&mut worker);
177 return Err(BoxError::BoxBootError {
178 message: "Timed out waiting for Sandbox log worker readiness".to_string(),
179 hint: None,
180 });
181 }
182 std::thread::sleep(Duration::from_millis(5));
183 }
184}
185
186#[cfg(target_os = "linux")]
187pub(crate) fn reap_failed_log_worker(worker: &mut std::process::Child) {
188 let deadline = Instant::now() + Duration::from_secs(1);
189 loop {
190 match worker.try_wait() {
191 Ok(Some(_)) => return,
192 Ok(None) if Instant::now() < deadline => {
193 std::thread::sleep(Duration::from_millis(10));
194 }
195 _ => break,
196 }
197 }
198 let _ = worker.kill();
199 let _ = worker.wait();
200}
201
202#[cfg(target_os = "linux")]
203pub(crate) fn bind_control_listener(path: &Path) -> Result<std::os::unix::net::UnixListener> {
204 use std::os::unix::fs::{FileTypeExt, PermissionsExt};
205
206 let parent = path.parent().ok_or_else(|| {
207 BoxError::ConfigError(format!("Sandbox socket has no parent: {}", path.display()))
208 })?;
209 create_private_dir(parent)?;
210 match std::fs::symlink_metadata(path) {
211 Ok(metadata) if metadata.file_type().is_socket() => {
212 std::fs::remove_file(path).map_err(BoxError::IoError)?;
213 }
214 Ok(_) => {
215 return Err(BoxError::BoxBootError {
216 message: format!(
217 "Refusing to replace non-socket Sandbox control path {}",
218 path.display()
219 ),
220 hint: None,
221 });
222 }
223 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
224 Err(error) => return Err(BoxError::IoError(error)),
225 }
226 let listener = std::os::unix::net::UnixListener::bind(path).map_err(BoxError::IoError)?;
227 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
228 .map_err(BoxError::IoError)?;
229 Ok(listener)
230}
231
232#[cfg(target_os = "linux")]
233pub(crate) fn duplicate_for_inheritance(fd: i32) -> Result<std::os::fd::OwnedFd> {
234 use std::os::fd::{FromRawFd, OwnedFd};
235
236 let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) };
237 if duplicate < 0 {
238 return Err(BoxError::IoError(std::io::Error::last_os_error()));
239 }
240 Ok(unsafe { OwnedFd::from_raw_fd(duplicate) })
242}
243
244#[cfg(target_os = "linux")]
245pub(crate) fn read_log_tail(path: &Path, limit: u64) -> Option<String> {
246 let mut file = File::open(path).ok()?;
247 let length = file.metadata().ok()?.len();
248 let offset = length.saturating_sub(limit);
249 file.seek(SeekFrom::Start(offset)).ok()?;
250
251 let mut bytes = Vec::with_capacity((length - offset) as usize);
252 file.take(limit).read_to_end(&mut bytes).ok()?;
253 let excerpt = String::from_utf8_lossy(&bytes).trim().to_string();
254 if excerpt.is_empty() {
255 None
256 } else if offset > 0 {
257 Some(format!("...{excerpt}"))
258 } else {
259 Some(excerpt)
260 }
261}
262
263#[cfg(all(test, target_os = "linux"))]
264mod tests {
265 use super::*;
266
267 #[test]
268 fn startup_log_excerpt_is_bounded_and_keeps_the_tail() {
269 let temporary = tempfile::tempdir().unwrap();
270 let path = temporary.path().join("runtime.stderr.log");
271 let mut contents = "x".repeat(START_FAILURE_LOG_LIMIT_BYTES as usize + 512);
272 contents.push_str("\nseccomp unknown architecture `NATIVE`\n");
273 std::fs::write(&path, contents).unwrap();
274
275 let excerpt = read_log_tail(&path, START_FAILURE_LOG_LIMIT_BYTES).unwrap();
276 assert!(excerpt.starts_with("..."));
277 assert!(excerpt.contains("seccomp unknown architecture `NATIVE`"));
278 assert!(excerpt.len() <= START_FAILURE_LOG_LIMIT_BYTES as usize + 3);
279 }
280}