Skip to main content

runtime/tools/exec/
container.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Container daemon admission and direct create argv
7//! (`crates/runtime/spec/Tools/Exec/Container.lean`). A successful admission
8//! is a daemon capability statement; it is not an isolation measurement.
9//! The caller must inspect the image and created container before start,
10//! and hand daemon cancellation to the existing backlog with the CLI child.
11
12use 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/// The CLI grammar and info schema to use for one daemon.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
28pub enum ContainerEngine {
29    Docker,
30    Podman,
31}
32
33/// One daemon whose reported controllers passed admission.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct ContainerRuntime {
36    program: PathBuf,
37    engine: ContainerEngine,
38    guardian: Option<PathBuf>,
39}
40
41/// The values that must remain paired for one container create.
42pub struct ContainerLaunch<'a> {
43    pub name: &'a str,
44    pub copy: &'a Path,
45    pub command: &'a Command,
46}
47
48/// The sole translation from the host copy to the container filesystem.
49const WORKDIR: &str = "/work";
50const MILLICPU_PER_CPU: u32 = 1000;
51
52impl ContainerRuntime {
53    /// Binds the harness whose private protocol retains cleanup after parent termination.
54    #[must_use]
55    pub fn guarded(mut self, harness: PathBuf) -> Self {
56        self.guardian = Some(harness);
57        self
58    }
59
60    /// Contacts the daemon under the bounded control-request budget.
61    ///
62    /// # Errors
63    /// Refuses unavailable, unsupported, or unreadable daemon capabilities.
64    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    /// Builds the daemon question for the host's bounded probe runner.
74    /// Unlike `--version`, `info` contacts the server and tests access.
75    #[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    /// Admits reported Linux cgroup v2 CPU, memory and process limits.
87    /// The host must obtain these bytes from a successful bounded info call.
88    ///
89    /// # Errors
90    /// `E_SANDBOX_DENIED` for missing, unknown or unsupported evidence.
91    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    /// Builds a stopped container. No shell interprets the target argv.
135    /// The source path must name the already synced copy, never the source tree.
136    ///
137    /// # Errors
138    /// `E_SANDBOX_DENIED` when mount or environment translation is ambiguous.
139    /// No process starts and no image is pulled by this method.
140    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        // Remove by the owned name before checking any daemon response.
512        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}