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 Command::new(&self.program)
71 }
72
73 #[must_use]
76 pub fn info_command(engine: ContainerEngine, program: &Path) -> Command {
77 let mut command = Command::new(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 = Command::new(&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 fn container_entrypoint_keeps_exactly_the_requested_program() {
345 let dir = tempfile::tempdir().unwrap();
346 for engine in [ContainerEngine::Docker, ContainerEngine::Podman] {
347 let info = match engine {
348 ContainerEngine::Docker => docker_info(),
349 ContainerEngine::Podman => json!({"host":{"os":"linux",
350 "cgroupVersion":"v2", "cgroupControllers":["cpu","memory","pids"]}}),
351 };
352 let runtime = admit(engine, &info).unwrap();
353 for program in [
354 "[\"/bin/sh\",\"-c\"]",
355 "[]",
356 "null",
357 "/bin/with\"quote",
358 "/bin/true",
359 ] {
360 let target = Command::new(program);
361 let command = runtime
362 .create_command(
363 &limits(),
364 &ContainerLaunch {
365 name: "owned-entrypoint",
366 copy: dir.path(),
367 command: &target,
368 },
369 )
370 .unwrap();
371 let entrypoint = command
372 .get_args()
373 .collect::<Vec<_>>()
374 .windows(2)
375 .find(|pair| pair[0] == "--entrypoint")
376 .unwrap()[1]
377 .to_str()
378 .unwrap()
379 .to_owned();
380 let decoded = match engine {
381 ContainerEngine::Docker => vec![entrypoint],
382 ContainerEngine::Podman => serde_json::from_str::<Vec<String>>(&entrypoint)
383 .unwrap_or_else(|_| vec![entrypoint]),
384 };
385 assert_eq!(decoded, vec![program.to_owned()], "{engine:?}: {program}");
386 }
387 }
388 }
389
390 #[cfg(unix)]
391 #[test]
392 fn podman_non_utf8_program_refuses_before_create() {
393 use std::ffi::OsString;
394 use std::os::unix::ffi::OsStringExt;
395
396 let runtime = admit(
397 ContainerEngine::Podman,
398 &json!({"host":{"os":"linux", "cgroupVersion":"v2",
399 "cgroupControllers":["cpu","memory","pids"]}}),
400 )
401 .unwrap();
402 let dir = tempfile::tempdir().unwrap();
403 let mut program = b"/bin/program-".to_vec();
404 program.push(0xff);
405 let target = Command::new(OsString::from_vec(program));
406 let err = runtime
407 .create_command(
408 &limits(),
409 &ContainerLaunch {
410 name: "owned-non-utf8",
411 copy: dir.path(),
412 command: &target,
413 },
414 )
415 .unwrap_err();
416 assert_eq!(err.code(), &AxCode::SandboxDenied);
417 assert!(!err.recovery().is_empty());
418 }
419
420 #[test]
421 fn ambiguous_mount_and_removed_environment_are_refused_before_create() {
422 let runtime = admit(ContainerEngine::Docker, &docker_info()).unwrap();
423 let dir = tempfile::tempdir().unwrap();
424 let mut target = Command::new("sh");
425 for path in [dir.path().join("copy,readonly"), PathBuf::from("relative")] {
426 assert!(
427 runtime
428 .create_command(
429 &limits(),
430 &ContainerLaunch {
431 name: "owned-copy",
432 copy: &path,
433 command: &target,
434 }
435 )
436 .is_err()
437 );
438 }
439 target.env_remove("LANG");
440 assert!(
441 runtime
442 .create_command(
443 &limits(),
444 &ContainerLaunch {
445 name: "owned-copy",
446 copy: dir.path(),
447 command: &target,
448 }
449 )
450 .is_err()
451 );
452 }
453
454 #[test]
455 #[ignore = "requires a Linux Docker/Podman daemon and an explicitly prepared local image ID"]
456 fn a_real_daemon_retains_the_requested_limits_on_a_stopped_container() {
457 let engine = std::env::var("SPRAWLING_CONTAINER_TEST_ENGINE").unwrap();
458 let engine = match engine.as_str() {
459 "docker" => ContainerEngine::Docker,
460 "podman" => ContainerEngine::Podman,
461 other => panic!("unsupported test engine: {other}"),
462 };
463 let program = PathBuf::from(std::env::var("SPRAWLING_CONTAINER_TEST_PROGRAM").unwrap());
464 let info = ContainerRuntime::info_command(engine, &program)
465 .output()
466 .unwrap();
467 assert!(info.status.success(), "daemon info failed");
468 let runtime = ContainerRuntime::admit(engine, program.clone(), &info.stdout).unwrap();
469 let mut limits = limits();
470 limits.image = kernel::ContainerImage::parse(
471 &std::env::var("SPRAWLING_CONTAINER_TEST_IMAGE").unwrap(),
472 )
473 .unwrap();
474 let dir = tempfile::tempdir().unwrap();
475 let name = format!(
476 "sprawling-create-{}-{}",
477 std::process::id(),
478 dir.path()
479 .file_name()
480 .unwrap()
481 .to_str()
482 .unwrap()
483 .replace('.', "")
484 );
485 let target = Command::new("/bin/true");
486 let mut create = runtime
487 .create_command(
488 &limits,
489 &ContainerLaunch {
490 name: &name,
491 copy: dir.path(),
492 command: &target,
493 },
494 )
495 .unwrap();
496 let observed = (|| -> Result<Value, Box<dyn std::error::Error>> {
497 let created = create.output()?;
498 if !created.status.success() {
499 return Err("the daemon refused create".into());
500 }
501 let inspected = Command::new(&program).args(["inspect", &name]).output()?;
502 if !inspected.status.success() {
503 return Err("the daemon refused inspect".into());
504 }
505 Ok(serde_json::from_slice(&inspected.stdout)?)
506 })();
507 let cleanup = Command::new(&program)
509 .args(["rm", "--force", "--volumes", &name])
510 .output()
511 .unwrap();
512 assert!(
513 cleanup.status.success(),
514 "the created container could not be removed"
515 );
516 let observed = observed.unwrap();
517 let first = observed.as_array().unwrap().first().unwrap();
518 match engine {
519 ContainerEngine::Docker => assert_eq!(
520 first["HostConfig"]["NanoCpus"],
521 json!(
522 u64::from(limits.cpu_millis.get())
523 .checked_mul(1_000_000)
524 .unwrap()
525 ),
526 ),
527 ContainerEngine::Podman => {
528 let quota = first["HostConfig"]["CpuQuota"].as_u64().unwrap();
529 let period = first["HostConfig"]["CpuPeriod"].as_u64().unwrap();
530 assert!(period > 0);
531 assert_eq!(
532 quota.checked_mul(u64::from(MILLICPU_PER_CPU)).unwrap(),
533 period
534 .checked_mul(u64::from(limits.cpu_millis.get()))
535 .unwrap()
536 );
537 }
538 }
539 assert_eq!(
540 first["Mounts"].as_array().unwrap().len(),
541 1,
542 "the prepared image must not declare anonymous volumes"
543 );
544 assert_eq!(first["State"]["Running"], json!(false));
545 assert_eq!(first["Config"]["User"], json!(limits.user.to_string()));
546 assert_eq!(first["HostConfig"]["NetworkMode"], json!("none"));
547 assert_eq!(
548 first["HostConfig"]["Memory"],
549 json!(limits.memory_bytes.get())
550 );
551 assert_eq!(
552 first["HostConfig"]["MemorySwap"],
553 json!(limits.memory_bytes.get())
554 );
555 assert_eq!(first["HostConfig"]["PidsLimit"], json!(limits.pids.get()));
556 assert_eq!(
557 first["Config"]["Entrypoint"],
558 match engine {
559 ContainerEngine::Docker => json!(["/bin/true"]),
560 ContainerEngine::Podman => json!("/bin/true"),
561 }
562 );
563 assert_eq!(first["Config"]["WorkingDir"], json!(WORKDIR));
564 }
565
566 proptest! {
567 #[test]
568 fn every_missing_docker_control_refuses(memory in any::<bool>(), cpu in any::<bool>(), pids in any::<bool>()) {
569 let mut info = docker_info();
570 info["MemoryLimit"] = json!(memory);
571 info["CpuCfsQuota"] = json!(cpu);
572 info["PidsLimit"] = json!(pids);
573 prop_assert_eq!(admit(ContainerEngine::Docker, &info).is_ok(), memory && cpu && pids);
574 }
575 #[test]
576 fn target_arguments_cannot_become_container_options(args in prop::collection::vec("[^\x00]{0,64}", 0..12)) {
577 let runtime = admit(ContainerEngine::Docker, &docker_info()).unwrap();
578 let dir = tempfile::tempdir().unwrap();
579 let limits = limits();
580 let mut target = Command::new("/bin/printf");
581 target.args(&args).env("LANG", "C");
582 let command = runtime.create_command(&limits, &ContainerLaunch {
583 name:"owned-copy", copy:dir.path(), command:&target,
584 }).unwrap();
585 let actual: Vec<_> = command.get_args().map(|arg| arg.to_str().unwrap()).collect();
586 let image = actual.iter().position(|arg| *arg == limits.image.as_str()).unwrap();
587 prop_assert_eq!(actual.get(image.saturating_add(1)..).unwrap(), args.iter().map(String::as_str).collect::<Vec<_>>());
588 prop_assert_eq!(command.get_program(), "container-cli");
589 prop_assert!(actual.windows(2).any(|pair| pair == ["--pull", "never"]));
590 prop_assert!(actual.windows(2).any(|pair| pair == ["--entrypoint", "/bin/printf"]));
591 prop_assert!(actual.windows(2).any(|pair| pair == ["--cpus", "1.250"]));
592 }
593 }
594}