Skip to main content

cargo_libc/
simulate.rs

1//! Tier 2: `cargo libc test` and `cargo libc run` execute each target binary
2//! through the `libc-runner` helper, which Cargo calls as its target runner.
3//!
4//! The helper first applies the direct glibc check to the binary (a newer
5//! host glibc would otherwise hide a too-new requirement under the local
6//! runner), then runs it under the kernel simulation from `kernel-abi-tools`:
7//! the local seccomp filter (host userland, no root) or a container with the
8//! target's userland. The selection travels from `cargo libc` to the helper
9//! in `CARGO_LIBC_RUN_*` environment variables.
10//!
11//! Without a runner, and with the local runner, the program uses the host's
12//! glibc. The helper then also compares the program's GLIBC version
13//! requirements with what the host's libraries define (the same test the
14//! loader makes) and refuses to start a program that cannot load there.
15
16use crate::{elf::ElfInfo, report, version::Version};
17use kernel_abi_tools::{
18    container::{self, Engine},
19    local, table_floor, KernelVersion, Runner, Target,
20};
21use std::{
22    collections::BTreeMap,
23    env,
24    ffi::OsString,
25    fs,
26    path::{Path, PathBuf},
27    process::Command,
28};
29
30const GLIBC_ENV: &str = "CARGO_LIBC_RUN_GLIBC";
31const DISTRO_ENV: &str = "CARGO_LIBC_RUN_DISTRO";
32const KERNEL_ENV: &str = "CARGO_LIBC_RUN_KERNEL";
33const RUNNER_ENV: &str = "CARGO_LIBC_RUN_RUNNER";
34const IMAGE_ENV: &str = "CARGO_LIBC_RUN_IMAGE";
35
36/// What `test`/`run` simulate. `distro` excludes `kernel`; `image` needs a
37/// kernel floor.
38#[derive(Clone, Debug, Default, PartialEq, Eq)]
39pub(crate) struct Simulation {
40    pub(crate) distro: Option<String>,
41    pub(crate) kernel: Option<String>,
42    pub(crate) runner: Option<Runner>,
43    pub(crate) image: Option<String>,
44}
45
46/// How one binary will run.
47#[derive(Debug, PartialEq, Eq)]
48pub(crate) enum Plan {
49    /// No kernel floor: run directly on the host (the glibc check still applies).
50    Direct,
51    Local(String),
52    Container(String, String),
53}
54
55impl Simulation {
56    /// The kernel target (with a preset's backports), if a floor was given.
57    pub(crate) fn target(&self) -> Result<Option<Target>, String> {
58        match (&self.distro, &self.kernel) {
59            (Some(_), Some(_)) => {
60                Err("--distro sets the kernel floor; do not combine it with --kernel".into())
61            }
62            (Some(id), None) => {
63                let d =
64                    kernel_abi_tools::distro(id).ok_or_else(|| format!("unknown distro {id:?}"))?;
65                Ok(Some(Target::distro(&d)))
66            }
67            (None, Some(k)) => Ok(Some(Target::kernel(KernelVersion::parse(k)?))),
68            (None, None) => Ok(None),
69        }
70    }
71
72    /// Applies `kernel_abi_tools::Runner::choose`: a requested runner wins; a
73    /// known image (`--image`, or the preset's) means the container, otherwise
74    /// the local filter. A preset's image is only a default, so `--runner
75    /// local` ignores it.
76    pub(crate) fn plan(&self) -> Result<(Plan, Option<Target>), String> {
77        let target = self.target()?;
78        let Some(t) = &target else {
79            if self.runner.is_some() || self.image.is_some() {
80                return Err(
81                    "--runner and --image need a kernel floor (--distro or --kernel)".into(),
82                );
83            }
84            return Ok((Plan::Direct, None));
85        };
86        let preset = self
87            .distro
88            .as_deref()
89            .and_then(kernel_abi_tools::distro)
90            .map(|d| d.image.to_string());
91        let image = match self.runner {
92            Some(Runner::Local) => self.image.clone(),
93            _ => self.image.clone().or(preset),
94        };
95        let plan = match Runner::choose(self.runner, image.as_deref())? {
96            Runner::Local => Plan::Local(t.label.clone()),
97            Runner::Container => Plan::Container(t.label.clone(), image.unwrap_or_default()),
98        };
99        Ok((plan, target))
100    }
101
102    /// Passes the selection to the helper.
103    /// The `libc-runner` variables; `None` means unset.
104    pub(crate) fn variables(&self, ceiling: Version) -> [(&'static str, Option<String>); 5] {
105        let runner = self.runner.map(|r| match r {
106            Runner::Local => "local".to_string(),
107            Runner::Container => "container".to_string(),
108        });
109        [
110            (GLIBC_ENV, Some(ceiling.to_string())),
111            (DISTRO_ENV, self.distro.clone()),
112            (KERNEL_ENV, self.kernel.clone()),
113            (RUNNER_ENV, runner),
114            (IMAGE_ENV, self.image.clone()),
115        ]
116    }
117
118    pub(crate) fn set_environment(&self, cmd: &mut Command, ceiling: Version) {
119        for (key, value) in self.variables(ceiling) {
120            match value {
121                Some(v) => cmd.env(key, v),
122                None => cmd.env_remove(key),
123            };
124        }
125    }
126
127    fn from_environment() -> Result<(Self, Version), String> {
128        let text = |key: &str| -> Result<Option<String>, String> {
129            match env::var_os(key) {
130                None => Ok(None),
131                Some(v) if v.is_empty() => Ok(None),
132                Some(v) => v
133                    .into_string()
134                    .map(Some)
135                    .map_err(|_| format!("{key} must be UTF-8")),
136            }
137        };
138        let ceiling = text(GLIBC_ENV)?
139            .ok_or_else(|| {
140                format!("{GLIBC_ENV} is missing; this helper is launched by cargo libc test/run")
141            })?
142            .parse::<Version>()?
143            .validate_glibc_target()?;
144        let sim = Simulation {
145            distro: text(DISTRO_ENV)?,
146            kernel: text(KERNEL_ENV)?,
147            runner: text(RUNNER_ENV)?.map(|r| Runner::parse(&r)).transpose()?,
148            image: text(IMAGE_ENV)?,
149        };
150        Ok((sim, ceiling))
151    }
152}
153
154/// Describes the plan for `cargo libc test/run` output.
155pub(crate) fn describe(plan: &Plan) -> String {
156    match plan {
157        Plan::Direct => "no kernel simulation (host kernel); glibc check only".into(),
158        Plan::Local(label) => {
159            format!("{label} with the local seccomp filter; userland (libc) is the host's")
160        }
161        Plan::Container(label, image) => format!("{label} in container image {image}"),
162    }
163}
164
165/// Whether the host's glibc can load a program that runs on the host userland.
166#[derive(Debug, PartialEq, Eq)]
167pub(crate) enum HostGlibc {
168    Satisfied,
169    /// `library GLIBC_x.y` requirements the host's libraries do not define.
170    Missing(Vec<String>),
171    /// The host libraries could not be read; the run proceeds with a warning.
172    Unknown(String),
173}
174
175/// Compare every non-weak GLIBC_* requirement with the versions `defined`
176/// reports for that library (the loader rejects any missing one).
177pub(crate) fn host_glibc(
178    info: &ElfInfo,
179    mut defined: impl FnMut(&str) -> Result<Vec<String>, String>,
180) -> HostGlibc {
181    let mut required: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
182    for need in info
183        .versions
184        .iter()
185        .filter(|n| !n.weak && n.name.starts_with("GLIBC_"))
186    {
187        required.entry(&need.library).or_default().push(&need.name);
188    }
189    let mut missing = Vec::new();
190    for (library, names) in required {
191        let available = match defined(library) {
192            Ok(available) => available,
193            Err(e) => return HostGlibc::Unknown(e),
194        };
195        for name in names {
196            if !available.iter().any(|a| a == name) {
197                missing.push(format!("{library} {name}"));
198            }
199        }
200    }
201    if missing.is_empty() {
202        HostGlibc::Satisfied
203    } else {
204        HostGlibc::Missing(missing)
205    }
206}
207
208/// The directory of the libc.so.6 this helper loaded: the host's glibc.
209fn host_libc_dir() -> Result<PathBuf, String> {
210    let maps = fs::read_to_string("/proc/self/maps")
211        .map_err(|e| format!("cannot read /proc/self/maps: {e}"))?;
212    maps.lines()
213        .filter_map(|line| line.split_whitespace().nth(5))
214        .map(Path::new)
215        .find(|path| path.file_name().is_some_and(|name| name == "libc.so.6"))
216        .and_then(Path::parent)
217        .map(Path::to_path_buf)
218        .ok_or_else(|| "this helper did not load a glibc libc.so.6".into())
219}
220
221/// The directories the loader searches before the system glibc: DT_RPATH
222/// (only without DT_RUNPATH), LD_LIBRARY_PATH, then DT_RUNPATH, with $ORIGIN
223/// expanded. Other tokens ($LIB, $PLATFORM) make the result uncertain.
224fn search_dirs(program: &Path, info: &ElfInfo) -> Result<Vec<PathBuf>, String> {
225    let origin = program
226        .canonicalize()
227        .ok()
228        .and_then(|p| p.parent().map(Path::to_path_buf))
229        .unwrap_or_else(|| PathBuf::from("."));
230    let expand = |list: &str| -> Result<Vec<PathBuf>, String> {
231        list.split(':')
232            .map(|entry| {
233                let entry = entry
234                    .replace("${ORIGIN}", &origin.to_string_lossy())
235                    .replace("$ORIGIN", &origin.to_string_lossy());
236                if entry.contains('$') {
237                    return Err(format!("cannot expand library search path entry {entry:?}"));
238                }
239                Ok(PathBuf::from(if entry.is_empty() { "." } else { &entry }))
240            })
241            .collect()
242    };
243    let mut dirs = Vec::new();
244    if info.runpath.is_empty() {
245        for list in &info.rpath {
246            dirs.extend(expand(list)?);
247        }
248    }
249    if let Some(list) = env::var_os("LD_LIBRARY_PATH").filter(|v| !v.is_empty()) {
250        dirs.extend(expand(&list.to_string_lossy())?);
251    }
252    for list in &info.runpath {
253        dirs.extend(expand(list)?);
254    }
255    Ok(dirs)
256}
257
258/// Version definitions of the `library` file the loader would use: the first
259/// one in the search directories, otherwise the one beside the host's libc.so.6.
260fn host_definitions(dirs: &[PathBuf], host: &Path, library: &str) -> Result<Vec<String>, String> {
261    if library.contains('/') {
262        return Err(format!("unexpected library name {library:?}"));
263    }
264    let path = dirs
265        .iter()
266        .map(|dir| dir.join(library))
267        .find(|path| path.is_file())
268        .unwrap_or_else(|| host.join(library));
269    let bytes = fs::read(&path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
270    Ok(crate::elf::inspect(&bytes)
271        .map_err(|e| format!("{}: {e}", path.display()))?
272        .definitions)
273}
274
275/// The highest GLIBC version among `library GLIBC_x.y` entries.
276fn highest(entries: &[String]) -> Option<Version> {
277    entries
278        .iter()
279        .filter_map(|e| e.rsplit_once(" GLIBC_")?.1.parse().ok())
280        .max()
281}
282
283/// Applies the host check for runs on the host userland: refuses (exit code
284/// 2) when the libraries the loader would use cannot satisfy the program, and
285/// warns and runs when it cannot tell.
286fn check_host(program: &str, info: &ElfInfo) -> Option<i32> {
287    let located =
288        host_libc_dir().and_then(|host| Ok((search_dirs(Path::new(program), info)?, host)));
289    let result = match &located {
290        Ok((dirs, host)) => host_glibc(info, |library| host_definitions(dirs, host, library)),
291        Err(e) => HostGlibc::Unknown(e.clone()),
292    };
293    match result {
294        HostGlibc::Satisfied => None,
295        HostGlibc::Unknown(e) => {
296            eprintln!("libc-runner: warning: host glibc not checked ({e}); running {program}");
297            None
298        }
299        HostGlibc::Missing(missing) => {
300            let need = highest(&missing).map_or_else(String::new, |v| format!(" {v} or newer"));
301            eprintln!(
302                "libc-runner: not running {program}: the host's libraries lack {}, so the \
303                 loader would refuse it. Run it in a container with glibc{need} (--runner \
304                 container --image IMAGE, or --distro), or build it with an older provider.",
305                missing.join(", ")
306            );
307            Some(2)
308        }
309    }
310}
311
312/// The `libc-runner` entry point: `libc-runner PROGRAM [ARGS...]`, Cargo's target
313/// runner. Refuses a program that needs a newer glibc than the target, or one the
314/// host's libraries cannot load when it runs on the host, then runs it under the
315/// selected kernel simulation, if any. Returns the program's exit code, or 2 when
316/// it was refused.
317///
318/// # Errors
319///
320/// Returns a message when the program is not given, its path or arguments are not
321/// UTF-8, the selection in the environment is invalid, the program cannot be read
322/// as ELF, or the runner cannot start it.
323pub fn run_program(mut args: impl Iterator<Item = OsString>) -> Result<i32, String> {
324    let program = args
325        .next()
326        .ok_or("usage: libc-runner PROGRAM [ARGS...] (Cargo's target runner)")?;
327    let program = program
328        .into_string()
329        .map_err(|_| "program path must be UTF-8".to_string())?;
330    let args = args
331        .map(|a| {
332            a.into_string()
333                .map_err(|_| "arguments must be UTF-8".to_string())
334        })
335        .collect::<Result<Vec<_>, _>>()?;
336    let (sim, ceiling) = Simulation::from_environment()?;
337
338    let info = report::read_elf(Path::new(&program))?;
339    let assessment = report::assess(&info, Some(ceiling));
340    if !assessment.errors.is_empty() {
341        for error in &assessment.errors {
342            eprintln!("libc-runner: {program}: {error}");
343        }
344        eprintln!("libc-runner: not running {program}: it needs a newer glibc than {ceiling}");
345        return Ok(2);
346    }
347
348    let (plan, target) = sim.plan()?;
349    // The container supplies its own glibc; the other plans use the host's.
350    if !matches!(plan, Plan::Container(..)) {
351        if let Some(code) = check_host(&program, &info) {
352            return Ok(code);
353        }
354    }
355    if let Some(t) = &target {
356        if t.kernel < table_floor() {
357            eprintln!(
358                "libc-runner: warning: kernel data starts at {}; {} is treated as {}",
359                table_floor(),
360                t.kernel,
361                table_floor()
362            );
363        }
364    }
365    match (&plan, &target) {
366        (Plan::Local(_), Some(t)) => local::run(t, &program, &args),
367        (Plan::Container(_, image), Some(t)) => {
368            container::run(&Engine::from_env(), t, image, &program, &args)
369        }
370        _ => {
371            let status = Command::new(&program)
372                .args(&args)
373                .status()
374                .map_err(|e| format!("cannot start {program}: {e}"))?;
375            Ok(status.code().unwrap_or(128))
376        }
377    }
378}
379
380/// The OCI runtime named in the engine options (`--runtime X` or
381/// `--runtime=X`), if any.
382pub(crate) fn runtime_from_engine_args(args: &[String]) -> Option<String> {
383    let mut it = args.iter();
384    while let Some(arg) = it.next() {
385        if arg == "--runtime" {
386            return it.next().cloned();
387        }
388        if let Some(value) = arg.strip_prefix("--runtime=") {
389            return Some(value.to_string());
390        }
391    }
392    None
393}
394
395fn first_line(program: &str, args: &[&str]) -> Result<String, String> {
396    let output = Command::new(program)
397        .args(args)
398        .output()
399        .map_err(|e| format!("cannot run {program}: {e}"))?;
400    if !output.status.success() {
401        return Err(format!("{program} {} failed", args.join(" ")));
402    }
403    Ok(String::from_utf8_lossy(&output.stdout)
404        .lines()
405        .next()
406        .unwrap_or("")
407        .trim()
408        .to_string())
409}
410
411/// The local runner: install the filter for `target` in a child and run
412/// `/bin/sh -c 'exit 0'` under it.
413fn check_local(target: &Target) -> Result<String, String> {
414    if !cfg!(all(target_os = "linux", target_arch = "x86_64")) {
415        return Err("needs an x86_64 Linux host".into());
416    }
417    match local::run(target, "/bin/sh", &["-c".into(), "exit 0".into()])? {
418        0 => Ok(format!(
419            "seccomp filter for {} installed without root; /bin/sh ran under it",
420            target.label
421        )),
422        code => Err(format!("/bin/sh exited with {code} under the filter")),
423    }
424}
425
426const UNTESTED: &str =
427    "engine found; no container started (select an image with --image or --distro)";
428
429/// The container runner: engine and runtime versions, then `true` in the
430/// image under the profile for `target`.
431fn check_container(target: &Target, image: Option<&str>) -> Result<String, String> {
432    let engine = Engine::from_env();
433    let version = first_line(&engine.program, &["--version"])?;
434    let runtime = match runtime_from_engine_args(&engine.engine_args) {
435        Some(r) => format!("runtime {r} ({})", first_line(&r, &["--version"])?),
436        None => "the engine's default runtime".into(),
437    };
438    println!("  container: {version}; {runtime}");
439    let Some(image) = image else {
440        return Ok(UNTESTED.into());
441    };
442    println!(
443        "  container: starting `true` in {image} under the {} profile (may pull the image)",
444        target.label
445    );
446    match container::run(&engine, target, image, "true", &[])? {
447        0 => Ok(format!(
448            "`true` ran in {image} under the {} profile",
449            target.label
450        )),
451        code => Err(format!(
452            "`true` exited with {code} in {image}. If the runtime failed to start, its \
453             libseccomp may not know every syscall in the profile, or the runtime itself \
454             (e.g. runc's statx) is caught by the older kernel's profile; crun 1.28 works: \
455             KERNEL_ABI_ENGINE_ARGS=\"--runtime /path/to/crun\""
456        )),
457    }
458}
459
460/// `cargo libc doctor` runner checks: reports both runners and returns whether
461/// the one `test`/`run` would choose is usable.
462pub(crate) fn doctor(simulation: &Simulation) -> Result<bool, String> {
463    let (plan, target) = simulation.plan()?;
464    println!("Kernel simulation for test/run: {}", describe(&plan));
465    let Some(target) = target else {
466        println!("Runner checks skipped: no kernel floor (--distro or --kernel).");
467        return Ok(true);
468    };
469    let image = match &plan {
470        Plan::Container(_, image) => Some(image.clone()),
471        _ => simulation.image.clone().or_else(|| {
472            simulation
473                .distro
474                .as_deref()
475                .and_then(kernel_abi_tools::distro)
476                .map(|d| d.image.to_string())
477        }),
478    };
479    let report = |name: &str, result: &Result<String, String>| match result {
480        Ok(text) if text == UNTESTED => println!("  {name}: not tested: {text}"),
481        Ok(text) => println!("  {name}: ok: {text}"),
482        Err(text) => println!("  {name}: unavailable: {text}"),
483    };
484    println!("Runner checks:");
485    let local = check_local(&target);
486    report("local", &local);
487    let container = check_container(&target, image.as_deref());
488    report("container", &container);
489    let selected = match plan {
490        Plan::Local(_) => local.is_ok(),
491        Plan::Container(..) => container.is_ok(),
492        Plan::Direct => true,
493    };
494    println!(
495        "Selected runner: {}",
496        if selected { "usable" } else { "NOT usable" }
497    );
498    Ok(selected)
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    fn sim(
506        distro: Option<&str>,
507        kernel: Option<&str>,
508        runner: Option<Runner>,
509        image: Option<&str>,
510    ) -> Simulation {
511        Simulation {
512            distro: distro.map(Into::into),
513            kernel: kernel.map(Into::into),
514            runner,
515            image: image.map(Into::into),
516        }
517    }
518
519    #[test]
520    fn plans_follow_the_runner_rules() {
521        let (plan, _) = sim(Some("sles-12-sp5"), None, None, None).plan().unwrap();
522        assert_eq!(
523            plan,
524            Plan::Container(
525                "SUSE Linux Enterprise Server 12 SP5 (Linux 4.12)".into(),
526                "registry.suse.com/suse/sles12sp5:latest".into()
527            )
528        );
529        let (plan, target) = sim(Some("sles-12-sp5"), None, Some(Runner::Local), None)
530            .plan()
531            .unwrap();
532        assert!(
533            matches!(plan, Plan::Local(_)),
534            "local ignores the preset image"
535        );
536        assert_eq!(target.unwrap().kernel.to_string(), "4.12");
537        let (plan, _) = sim(None, Some("3.10"), None, None).plan().unwrap();
538        assert_eq!(plan, Plan::Local("Linux 3.10".into()));
539        let (plan, _) = sim(None, Some("3.10"), None, Some("centos:7"))
540            .plan()
541            .unwrap();
542        assert_eq!(
543            plan,
544            Plan::Container("Linux 3.10".into(), "centos:7".into())
545        );
546        assert_eq!(sim(None, None, None, None).plan().unwrap().0, Plan::Direct);
547        for bad in [
548            sim(None, None, Some(Runner::Local), None),
549            sim(None, None, None, Some("centos:7")),
550            sim(None, Some("3.10"), Some(Runner::Container), None),
551            sim(None, Some("3.10"), Some(Runner::Local), Some("centos:7")),
552            sim(Some("rhel-7"), Some("3.10"), None, None),
553            sim(Some("no-such-distro"), None, None, None),
554        ] {
555            assert!(bad.plan().is_err(), "{bad:?}");
556        }
557    }
558
559    fn needs(entries: &[(&str, &str, bool)]) -> ElfInfo {
560        ElfInfo {
561            machine: 62,
562            interpreter: None,
563            needed: Vec::new(),
564            rpath: Vec::new(),
565            runpath: Vec::new(),
566            versions: entries
567                .iter()
568                .map(|(library, name, weak)| crate::elf::VersionNeed {
569                    library: (*library).into(),
570                    name: (*name).into(),
571                    weak: *weak,
572                })
573                .collect(),
574            definitions: Vec::new(),
575        }
576    }
577
578    #[test]
579    fn host_glibc_compares_required_with_defined_versions() {
580        let host = |library: &str| -> Result<Vec<String>, String> {
581            match library {
582                "libc.so.6" => Ok(vec!["GLIBC_2.2.5".into(), "GLIBC_2.17".into()]),
583                "libm.so.6" => Ok(vec!["GLIBC_2.2.5".into()]),
584                _ => Err(format!("cannot read {library}")),
585            }
586        };
587        let fits = needs(&[
588            ("libc.so.6", "GLIBC_2.17", false),
589            ("libm.so.6", "GLIBC_2.2.5", false),
590        ]);
591        assert_eq!(host_glibc(&fits, host), HostGlibc::Satisfied);
592        let too_new = needs(&[
593            ("libc.so.6", "GLIBC_2.34", false),
594            ("libm.so.6", "GLIBC_2.29", false),
595            ("libc.so.6", "GLIBC_2.38", true),
596            ("libgcc_s.so.1", "GCC_3.0", false),
597        ]);
598        let HostGlibc::Missing(missing) = host_glibc(&too_new, host) else {
599            panic!("expected missing versions")
600        };
601        assert_eq!(missing, ["libc.so.6 GLIBC_2.34", "libm.so.6 GLIBC_2.29"]);
602        assert_eq!(highest(&missing), Some("2.34".parse().unwrap()));
603        let unreadable = needs(&[("libdl.so.2", "GLIBC_2.2.5", false)]);
604        assert!(matches!(
605            host_glibc(&unreadable, host),
606            HostGlibc::Unknown(_)
607        ));
608    }
609
610    #[test]
611    fn search_dirs_follow_the_loader_order_and_expand_origin() {
612        let mut info = needs(&[]);
613        info.rpath.push("/r1:$ORIGIN/r2".into());
614        let dirs = search_dirs(Path::new("/nonexistent/bin/app"), &info).unwrap();
615        assert!(dirs.starts_with(&[PathBuf::from("/r1")]));
616        assert!(dirs[1].ends_with("r2"), "{dirs:?}");
617        // DT_RUNPATH disables DT_RPATH and comes after LD_LIBRARY_PATH.
618        info.runpath.push("/run".into());
619        let dirs = search_dirs(Path::new("/nonexistent/bin/app"), &info).unwrap();
620        assert!(!dirs.contains(&PathBuf::from("/r1")));
621        assert_eq!(dirs.last(), Some(&PathBuf::from("/run")));
622        info.runpath.push("/x/$LIB".into());
623        assert!(search_dirs(Path::new("/nonexistent/bin/app"), &info).is_err());
624    }
625
626    #[test]
627    fn finds_the_runtime_in_engine_options() {
628        let words = |s: &str| s.split_whitespace().map(String::from).collect::<Vec<_>>();
629        assert_eq!(
630            runtime_from_engine_args(&words("--log-level warn --runtime /opt/crun")),
631            Some("/opt/crun".into())
632        );
633        assert_eq!(
634            runtime_from_engine_args(&words("--runtime=crun")),
635            Some("crun".into())
636        );
637        assert_eq!(runtime_from_engine_args(&words("--log-level warn")), None);
638    }
639
640    #[test]
641    fn selection_round_trips_through_the_environment() {
642        let mut cmd = Command::new("x");
643        let s = sim(Some("rhel-7"), None, Some(Runner::Local), None);
644        s.set_environment(&mut cmd, "2.17".parse().unwrap());
645        let envs: Vec<_> = cmd
646            .get_envs()
647            .map(|(k, v)| {
648                (
649                    k.to_string_lossy().into_owned(),
650                    v.map(|v| v.to_string_lossy().into_owned()),
651                )
652            })
653            .collect();
654        assert!(envs.contains(&(GLIBC_ENV.into(), Some("2.17".into()))));
655        assert!(envs.contains(&(DISTRO_ENV.into(), Some("rhel-7".into()))));
656        assert!(envs.contains(&(RUNNER_ENV.into(), Some("local".into()))));
657        assert!(
658            envs.contains(&(KERNEL_ENV.into(), None)),
659            "stale values are removed"
660        );
661    }
662}