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        Command::new(&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 = 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    /// 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 = 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        // Remove by the owned name before checking any daemon response.
508        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}