1use std::path::{Path, PathBuf};
13use std::process::Command;
14
15use kernel::{AxCode, AxError, ContainerLimits};
16use serde_json::Value;
17
18mod cleanup;
19mod guardian;
20pub use guardian::run_container_guard;
21mod control;
22mod inspection;
23mod lifetime;
24pub(crate) use lifetime::ContainerLease;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
28pub enum ContainerEngine {
29 Docker,
30 Podman,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct ContainerRuntime {
36 program: PathBuf,
37 engine: ContainerEngine,
38 guardian: Option<PathBuf>,
39}
40
41pub struct ContainerLaunch<'a> {
43 pub name: &'a str,
44 pub copy: &'a Path,
45 pub command: &'a Command,
46}
47
48const WORKDIR: &str = "/work";
50const MILLICPU_PER_CPU: u32 = 1000;
51
52impl ContainerRuntime {
53 #[must_use]
55 pub fn guarded(mut self, harness: PathBuf) -> Self {
56 self.guardian = Some(harness);
57 self
58 }
59
60 pub fn probe(engine: ContainerEngine, program: PathBuf) -> Result<Self, AxError> {
65 let bytes = control::checked(&mut Self::info_command(engine, &program))?;
66 Self::admit(engine, program, &bytes)
67 }
68
69 fn command(&self) -> Command {
70 child::command(&self.program)
71 }
72
73 #[must_use]
76 pub fn info_command(engine: ContainerEngine, program: &Path) -> Command {
77 let mut command = child::command(program);
78 command.arg("info").arg("--format");
79 match engine {
80 ContainerEngine::Docker => command.arg("{{json .}}"),
81 ContainerEngine::Podman => command.arg("json"),
82 };
83 command
84 }
85
86 pub fn admit(engine: ContainerEngine, program: PathBuf, info: &[u8]) -> Result<Self, AxError> {
92 let info: Value = serde_json::from_slice(info)
93 .map_err(|err| denied("read container daemon capabilities", err.to_string()))?;
94 let supported = match engine {
95 ContainerEngine::Docker => {
96 info.get("OSType").and_then(Value::as_str) == Some("linux")
97 && info.get("CgroupVersion").and_then(Value::as_str) == Some("2")
98 && info.get("MemoryLimit").and_then(Value::as_bool) == Some(true)
99 && info.get("CpuCfsQuota").and_then(Value::as_bool) == Some(true)
100 && info.get("PidsLimit").and_then(Value::as_bool) == Some(true)
101 }
102 ContainerEngine::Podman => {
103 let host = info.get("host");
104 host.and_then(|host| host.get("os")).and_then(Value::as_str) == Some("linux")
105 && host
106 .and_then(|host| host.get("cgroupVersion"))
107 .and_then(Value::as_str)
108 == Some("v2")
109 && host
110 .and_then(|host| host.get("cgroupControllers"))
111 .and_then(Value::as_array)
112 .is_some_and(|controllers| {
113 ["cpu", "memory", "pids"].iter().all(|needed| {
114 controllers
115 .iter()
116 .any(|controller| controller.as_str() == Some(needed))
117 })
118 })
119 }
120 };
121 if !supported {
122 return Err(denied(
123 "admit the container daemon",
124 "the daemon does not report Linux cgroup v2 with CPU, memory and process controllers",
125 ));
126 }
127 Ok(Self {
128 program,
129 engine,
130 guardian: None,
131 })
132 }
133
134 pub fn create_command(
141 &self,
142 limits: &ContainerLimits,
143 launch: &ContainerLaunch<'_>,
144 ) -> Result<Command, AxError> {
145 if launch.name.is_empty()
146 || !launch
147 .name
148 .bytes()
149 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
150 {
151 return Err(denied(
152 "name the container",
153 "use an owned alphanumeric container name",
154 ));
155 }
156 let copy = launch
157 .copy
158 .to_str()
159 .filter(|copy| {
160 !copy.is_empty()
161 && !copy.contains([',', '\n', '\r', '\0'])
162 && launch.copy.is_absolute()
163 })
164 .ok_or_else(|| {
165 denied(
166 "mount the sandbox copy",
167 "the copy path cannot be represented as one absolute bind mount",
168 )
169 })?;
170 if launch.command.get_program().is_empty() {
171 return Err(denied(
172 "start the container program",
173 "the program is empty",
174 ));
175 }
176 let cpu = limits
177 .cpu_millis
178 .get()
179 .checked_div(MILLICPU_PER_CPU)
180 .zip(limits.cpu_millis.get().checked_rem(MILLICPU_PER_CPU))
181 .ok_or_else(|| {
182 denied(
183 "translate the container CPU limit",
184 "millicpu conversion failed",
185 )
186 })?;
187 let mut command = child::command(&self.program);
188 command
189 .args(["create", "--pull", "never", "--name"])
190 .arg(launch.name)
191 .args(["--network", "none", "--read-only", "--cap-drop", "ALL"])
192 .args(["--security-opt", "no-new-privileges", "--user"])
193 .arg(limits.user.to_string())
194 .arg("--cpus")
195 .arg(format!("{}.{:03}", cpu.0, cpu.1))
196 .arg("--memory")
197 .arg(limits.memory_bytes.to_string())
198 .arg("--memory-swap")
199 .arg(limits.memory_bytes.to_string())
200 .arg("--pids-limit")
201 .arg(limits.pids.to_string())
202 .arg("--mount")
203 .arg(format!("type=bind,src={copy},dst={WORKDIR}"))
204 .arg("--workdir")
205 .arg(WORKDIR)
206 .arg("--entrypoint");
207 match self.engine {
208 ContainerEngine::Docker => command.arg(launch.command.get_program()),
209 ContainerEngine::Podman => {
210 let program = launch.command.get_program().to_str().ok_or_else(|| {
211 denied(
212 "encode the Podman entrypoint",
213 "a non-UTF-8 program cannot be represented without changing its identity",
214 )
215 })?;
216 command.arg(
217 serde_json::to_string(&[program]).map_err(|source| {
218 denied("encode the Podman entrypoint", source.to_string())
219 })?,
220 )
221 }
222 };
223 for (name, value) in launch.command.get_envs() {
224 let value = value.ok_or_else(|| {
225 denied(
226 "pass the container environment",
227 "an explicitly removed variable cannot be translated",
228 )
229 })?;
230 let (Some(name), Some(value)) = (name.to_str(), value.to_str()) else {
231 return Err(denied(
232 "pass the container environment",
233 "a non-UTF-8 variable cannot be translated",
234 ));
235 };
236 if name.is_empty() || name.contains(['=', '\0']) || value.contains('\0') {
237 return Err(denied(
238 "pass the container environment",
239 "an environment assignment has an invalid name or value",
240 ));
241 }
242 command.arg("--env").arg(format!("{name}={value}"));
243 }
244 command
245 .arg(limits.image.as_str())
246 .args(launch.command.get_args());
247 Ok(command)
248 }
249}
250
251fn denied(action: &str, subject: impl std::fmt::Display) -> AxError {
252 AxError::failure(AxCode::SandboxDenied, action, subject.to_string()).with_recovery(
253 "make the Linux container daemon accessible with cgroup v2 CPU, memory and process controllers, or explicitly choose an existing sandbox arm",
254 )
255}
256
257#[cfg(test)]
258#[allow(
259 clippy::unwrap_used,
260 clippy::expect_used,
261 clippy::panic,
262 clippy::indexing_slicing,
263 reason = "test code"
264)]
265mod tests {
266 use super::*;
267 use proptest::prelude::*;
268 use serde_json::json;
269
270 fn docker_info() -> Value {
271 json!({"OSType":"linux", "CgroupVersion":"2", "MemoryLimit":true,
272 "CpuCfsQuota":true, "PidsLimit":true})
273 }
274
275 fn admit(engine: ContainerEngine, info: &Value) -> Result<ContainerRuntime, AxError> {
276 ContainerRuntime::admit(
277 engine,
278 PathBuf::from("container-cli"),
279 &serde_json::to_vec(info).unwrap(),
280 )
281 }
282
283 fn limits() -> ContainerLimits {
284 serde_json::from_value(json!({"image": format!("sha256:{}", "a".repeat(64)),
285 "user":1000, "cpu_millis":1250, "memory_bytes":67108864, "pids":64}))
286 .unwrap()
287 }
288
289 #[test]
290 fn a_cli_without_complete_daemon_evidence_is_refused() {
291 for engine in [ContainerEngine::Docker, ContainerEngine::Podman] {
292 let err = admit(engine, &json!({"version":"99"})).unwrap_err();
293 assert_eq!(err.code(), &AxCode::SandboxDenied);
294 assert!(!err.recovery().is_empty());
295 }
296 for field in [
297 "OSType",
298 "CgroupVersion",
299 "MemoryLimit",
300 "CpuCfsQuota",
301 "PidsLimit",
302 ] {
303 let mut missing = docker_info();
304 missing.as_object_mut().unwrap().remove(field);
305 assert!(admit(ContainerEngine::Docker, &missing).is_err(), "{field}");
306 }
307 let mut unsupported = docker_info();
308 unsupported["OSType"] = json!("windows");
309 assert!(admit(ContainerEngine::Docker, &unsupported).is_err());
310 unsupported["OSType"] = json!("linux");
311 unsupported["MemoryLimit"] = json!(false);
312 assert!(admit(ContainerEngine::Docker, &unsupported).is_err());
313 assert!(admit(ContainerEngine::Docker, &docker_info()).is_ok());
314 }
315
316 #[test]
317 fn rootless_podman_needs_each_delegated_controller() {
318 for controllers in [
319 vec!["cpu", "memory"],
320 vec!["memory", "pids"],
321 vec!["cpu", "pids"],
322 vec![],
323 ] {
324 assert!(
325 admit(
326 ContainerEngine::Podman,
327 &json!({"host":{"os":"linux",
328 "cgroupVersion":"v2", "cgroupControllers":controllers}})
329 )
330 .is_err()
331 );
332 }
333 assert!(
334 admit(
335 ContainerEngine::Podman,
336 &json!({"host":{"os":"linux",
337 "cgroupVersion":"v2", "cgroupControllers":["cpu","memory","pids"]}})
338 )
339 .is_ok()
340 );
341 }
342
343 #[test]
344 #[expect(clippy::disallowed_methods, reason = "test fixture (child D4)")]
345 fn container_entrypoint_keeps_exactly_the_requested_program() {
346 let dir = tempfile::tempdir().unwrap();
347 for engine in [ContainerEngine::Docker, ContainerEngine::Podman] {
348 let info = match engine {
349 ContainerEngine::Docker => docker_info(),
350 ContainerEngine::Podman => json!({"host":{"os":"linux",
351 "cgroupVersion":"v2", "cgroupControllers":["cpu","memory","pids"]}}),
352 };
353 let runtime = admit(engine, &info).unwrap();
354 for program in [
355 "[\"/bin/sh\",\"-c\"]",
356 "[]",
357 "null",
358 "/bin/with\"quote",
359 "/bin/true",
360 ] {
361 let target = Command::new(program);
362 let command = runtime
363 .create_command(
364 &limits(),
365 &ContainerLaunch {
366 name: "owned-entrypoint",
367 copy: dir.path(),
368 command: &target,
369 },
370 )
371 .unwrap();
372 let entrypoint = command
373 .get_args()
374 .collect::<Vec<_>>()
375 .windows(2)
376 .find(|pair| pair[0] == "--entrypoint")
377 .unwrap()[1]
378 .to_str()
379 .unwrap()
380 .to_owned();
381 let decoded = match engine {
382 ContainerEngine::Docker => vec![entrypoint],
383 ContainerEngine::Podman => serde_json::from_str::<Vec<String>>(&entrypoint)
384 .unwrap_or_else(|_| vec![entrypoint]),
385 };
386 assert_eq!(decoded, vec![program.to_owned()], "{engine:?}: {program}");
387 }
388 }
389 }
390
391 #[cfg(unix)]
392 #[test]
393 #[expect(clippy::disallowed_methods, reason = "test fixture (child D4)")]
394 fn podman_non_utf8_program_refuses_before_create() {
395 use std::ffi::OsString;
396 use std::os::unix::ffi::OsStringExt;
397
398 let runtime = admit(
399 ContainerEngine::Podman,
400 &json!({"host":{"os":"linux", "cgroupVersion":"v2",
401 "cgroupControllers":["cpu","memory","pids"]}}),
402 )
403 .unwrap();
404 let dir = tempfile::tempdir().unwrap();
405 let mut program = b"/bin/program-".to_vec();
406 program.push(0xff);
407 let target = Command::new(OsString::from_vec(program));
408 let err = runtime
409 .create_command(
410 &limits(),
411 &ContainerLaunch {
412 name: "owned-non-utf8",
413 copy: dir.path(),
414 command: &target,
415 },
416 )
417 .unwrap_err();
418 assert_eq!(err.code(), &AxCode::SandboxDenied);
419 assert!(!err.recovery().is_empty());
420 }
421
422 #[test]
423 #[expect(clippy::disallowed_methods, reason = "test fixture (child D4)")]
424 fn ambiguous_mount_and_removed_environment_are_refused_before_create() {
425 let runtime = admit(ContainerEngine::Docker, &docker_info()).unwrap();
426 let dir = tempfile::tempdir().unwrap();
427 let mut target = Command::new("sh");
428 for path in [dir.path().join("copy,readonly"), PathBuf::from("relative")] {
429 assert!(
430 runtime
431 .create_command(
432 &limits(),
433 &ContainerLaunch {
434 name: "owned-copy",
435 copy: &path,
436 command: &target,
437 }
438 )
439 .is_err()
440 );
441 }
442 target.env_remove("LANG");
443 assert!(
444 runtime
445 .create_command(
446 &limits(),
447 &ContainerLaunch {
448 name: "owned-copy",
449 copy: dir.path(),
450 command: &target,
451 }
452 )
453 .is_err()
454 );
455 }
456
457 #[test]
458 #[ignore = "requires a Linux Docker/Podman daemon and an explicitly prepared local image ID"]
459 #[expect(clippy::disallowed_methods, reason = "test fixture (child D4)")]
460 fn a_real_daemon_retains_the_requested_limits_on_a_stopped_container() {
461 let engine = std::env::var("SPRAWLING_CONTAINER_TEST_ENGINE").unwrap();
462 let engine = match engine.as_str() {
463 "docker" => ContainerEngine::Docker,
464 "podman" => ContainerEngine::Podman,
465 other => panic!("unsupported test engine: {other}"),
466 };
467 let program = PathBuf::from(std::env::var("SPRAWLING_CONTAINER_TEST_PROGRAM").unwrap());
468 let info = ContainerRuntime::info_command(engine, &program)
469 .output()
470 .unwrap();
471 assert!(info.status.success(), "daemon info failed");
472 let runtime = ContainerRuntime::admit(engine, program.clone(), &info.stdout).unwrap();
473 let mut limits = limits();
474 limits.image = kernel::ContainerImage::parse(
475 &std::env::var("SPRAWLING_CONTAINER_TEST_IMAGE").unwrap(),
476 )
477 .unwrap();
478 let dir = tempfile::tempdir().unwrap();
479 let name = format!(
480 "sprawling-create-{}-{}",
481 std::process::id(),
482 dir.path()
483 .file_name()
484 .unwrap()
485 .to_str()
486 .unwrap()
487 .replace('.', "")
488 );
489 let target = Command::new("/bin/true");
490 let mut create = runtime
491 .create_command(
492 &limits,
493 &ContainerLaunch {
494 name: &name,
495 copy: dir.path(),
496 command: &target,
497 },
498 )
499 .unwrap();
500 let observed = (|| -> Result<Value, Box<dyn std::error::Error>> {
501 let created = create.output()?;
502 if !created.status.success() {
503 return Err("the daemon refused create".into());
504 }
505 let inspected = Command::new(&program).args(["inspect", &name]).output()?;
506 if !inspected.status.success() {
507 return Err("the daemon refused inspect".into());
508 }
509 Ok(serde_json::from_slice(&inspected.stdout)?)
510 })();
511 let cleanup = Command::new(&program)
513 .args(["rm", "--force", "--volumes", &name])
514 .output()
515 .unwrap();
516 assert!(
517 cleanup.status.success(),
518 "the created container could not be removed"
519 );
520 let observed = observed.unwrap();
521 let first = observed.as_array().unwrap().first().unwrap();
522 match engine {
523 ContainerEngine::Docker => assert_eq!(
524 first["HostConfig"]["NanoCpus"],
525 json!(
526 u64::from(limits.cpu_millis.get())
527 .checked_mul(1_000_000)
528 .unwrap()
529 ),
530 ),
531 ContainerEngine::Podman => {
532 let quota = first["HostConfig"]["CpuQuota"].as_u64().unwrap();
533 let period = first["HostConfig"]["CpuPeriod"].as_u64().unwrap();
534 assert!(period > 0);
535 assert_eq!(
536 quota.checked_mul(u64::from(MILLICPU_PER_CPU)).unwrap(),
537 period
538 .checked_mul(u64::from(limits.cpu_millis.get()))
539 .unwrap()
540 );
541 }
542 }
543 assert_eq!(
544 first["Mounts"].as_array().unwrap().len(),
545 1,
546 "the prepared image must not declare anonymous volumes"
547 );
548 assert_eq!(first["State"]["Running"], json!(false));
549 assert_eq!(first["Config"]["User"], json!(limits.user.to_string()));
550 assert_eq!(first["HostConfig"]["NetworkMode"], json!("none"));
551 assert_eq!(
552 first["HostConfig"]["Memory"],
553 json!(limits.memory_bytes.get())
554 );
555 assert_eq!(
556 first["HostConfig"]["MemorySwap"],
557 json!(limits.memory_bytes.get())
558 );
559 assert_eq!(first["HostConfig"]["PidsLimit"], json!(limits.pids.get()));
560 assert_eq!(
561 first["Config"]["Entrypoint"],
562 match engine {
563 ContainerEngine::Docker => json!(["/bin/true"]),
564 ContainerEngine::Podman => json!("/bin/true"),
565 }
566 );
567 assert_eq!(first["Config"]["WorkingDir"], json!(WORKDIR));
568 }
569
570 proptest! {
571 #[test]
572 fn every_missing_docker_control_refuses(memory in any::<bool>(), cpu in any::<bool>(), pids in any::<bool>()) {
573 let mut info = docker_info();
574 info["MemoryLimit"] = json!(memory);
575 info["CpuCfsQuota"] = json!(cpu);
576 info["PidsLimit"] = json!(pids);
577 prop_assert_eq!(admit(ContainerEngine::Docker, &info).is_ok(), memory && cpu && pids);
578 }
579 #[test]
580 fn target_arguments_cannot_become_container_options(args in prop::collection::vec("[^\x00]{0,64}", 0..12)) {
581 let runtime = admit(ContainerEngine::Docker, &docker_info()).unwrap();
582 let dir = tempfile::tempdir().unwrap();
583 let limits = limits();
584 #[expect(clippy::disallowed_methods, reason = "test fixture (child D4)")]
585 let mut target = Command::new("/bin/printf");
586 target.args(&args).env("LANG", "C");
587 let command = runtime.create_command(&limits, &ContainerLaunch {
588 name:"owned-copy", copy:dir.path(), command:&target,
589 }).unwrap();
590 let actual: Vec<_> = command.get_args().map(|arg| arg.to_str().unwrap()).collect();
591 let image = actual.iter().position(|arg| *arg == limits.image.as_str()).unwrap();
592 prop_assert_eq!(actual.get(image.saturating_add(1)..).unwrap(), args.iter().map(String::as_str).collect::<Vec<_>>());
593 prop_assert_eq!(command.get_program(), "container-cli");
594 prop_assert!(actual.windows(2).any(|pair| pair == ["--pull", "never"]));
595 prop_assert!(actual.windows(2).any(|pair| pair == ["--entrypoint", "/bin/printf"]));
596 prop_assert!(actual.windows(2).any(|pair| pair == ["--cpus", "1.250"]));
597 }
598 }
599}