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 struct Simulation {
40    pub distro: Option<String>,
41    pub kernel: Option<String>,
42    pub runner: Option<Runner>,
43    pub image: Option<String>,
44}
45
46/// How one binary will run.
47#[derive(Debug, PartialEq, Eq)]
48pub 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 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 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 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 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 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 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 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...]`.
313pub fn run_program(mut args: impl Iterator<Item = OsString>) -> Result<i32, String> {
314    let program = args
315        .next()
316        .ok_or("usage: libc-runner PROGRAM [ARGS...] (Cargo's target runner)")?;
317    let program = program
318        .into_string()
319        .map_err(|_| "program path must be UTF-8".to_string())?;
320    let args = args
321        .map(|a| {
322            a.into_string()
323                .map_err(|_| "arguments must be UTF-8".to_string())
324        })
325        .collect::<Result<Vec<_>, _>>()?;
326    let (sim, ceiling) = Simulation::from_environment()?;
327
328    let info = report::read_elf(Path::new(&program))?;
329    let assessment = report::assess(&info, Some(ceiling));
330    if !assessment.errors.is_empty() {
331        for error in &assessment.errors {
332            eprintln!("libc-runner: {program}: {error}");
333        }
334        eprintln!("libc-runner: not running {program}: it needs a newer glibc than {ceiling}");
335        return Ok(2);
336    }
337
338    let (plan, target) = sim.plan()?;
339    // The container supplies its own glibc; the other plans use the host's.
340    if !matches!(plan, Plan::Container(..)) {
341        if let Some(code) = check_host(&program, &info) {
342            return Ok(code);
343        }
344    }
345    if let Some(t) = &target {
346        if t.kernel < table_floor() {
347            eprintln!(
348                "libc-runner: warning: kernel data starts at {}; {} is treated as {}",
349                table_floor(),
350                t.kernel,
351                table_floor()
352            );
353        }
354    }
355    match (&plan, &target) {
356        (Plan::Local(_), Some(t)) => local::run(t, &program, &args),
357        (Plan::Container(_, image), Some(t)) => {
358            container::run(&Engine::from_env(), t, image, &program, &args)
359        }
360        _ => {
361            let status = Command::new(&program)
362                .args(&args)
363                .status()
364                .map_err(|e| format!("cannot start {program}: {e}"))?;
365            Ok(status.code().unwrap_or(128))
366        }
367    }
368}
369
370/// The OCI runtime named in the engine options (`--runtime X` or
371/// `--runtime=X`), if any.
372pub fn runtime_from_engine_args(args: &[String]) -> Option<String> {
373    let mut it = args.iter();
374    while let Some(arg) = it.next() {
375        if arg == "--runtime" {
376            return it.next().cloned();
377        }
378        if let Some(value) = arg.strip_prefix("--runtime=") {
379            return Some(value.to_string());
380        }
381    }
382    None
383}
384
385fn first_line(program: &str, args: &[&str]) -> Result<String, String> {
386    let output = Command::new(program)
387        .args(args)
388        .output()
389        .map_err(|e| format!("cannot run {program}: {e}"))?;
390    if !output.status.success() {
391        return Err(format!("{program} {} failed", args.join(" ")));
392    }
393    Ok(String::from_utf8_lossy(&output.stdout)
394        .lines()
395        .next()
396        .unwrap_or("")
397        .trim()
398        .to_string())
399}
400
401/// The local runner: install the filter for `target` in a child and run
402/// `/bin/sh -c 'exit 0'` under it.
403fn check_local(target: &Target) -> Result<String, String> {
404    if !cfg!(all(target_os = "linux", target_arch = "x86_64")) {
405        return Err("needs an x86_64 Linux host".into());
406    }
407    match local::run(target, "/bin/sh", &["-c".into(), "exit 0".into()])? {
408        0 => Ok(format!(
409            "seccomp filter for {} installed without root; /bin/sh ran under it",
410            target.label
411        )),
412        code => Err(format!("/bin/sh exited with {code} under the filter")),
413    }
414}
415
416const UNTESTED: &str =
417    "engine found; no container started (select an image with --image or --distro)";
418
419/// The container runner: engine and runtime versions, then `true` in the
420/// image under the profile for `target`.
421fn check_container(target: &Target, image: Option<&str>) -> Result<String, String> {
422    let engine = Engine::from_env();
423    let version = first_line(&engine.program, &["--version"])?;
424    let runtime = match runtime_from_engine_args(&engine.engine_args) {
425        Some(r) => format!("runtime {r} ({})", first_line(&r, &["--version"])?),
426        None => "the engine's default runtime".into(),
427    };
428    println!("  container: {version}; {runtime}");
429    let Some(image) = image else {
430        return Ok(UNTESTED.into());
431    };
432    println!(
433        "  container: starting `true` in {image} under the {} profile (may pull the image)",
434        target.label
435    );
436    match container::run(&engine, target, image, "true", &[])? {
437        0 => Ok(format!(
438            "`true` ran in {image} under the {} profile",
439            target.label
440        )),
441        code => Err(format!(
442            "`true` exited with {code} in {image}. If the runtime failed to start, its \
443             libseccomp may not know every syscall in the profile, or the runtime itself \
444             (e.g. runc's statx) is caught by the older kernel's profile; crun 1.28 works: \
445             KERNEL_ABI_ENGINE_ARGS=\"--runtime /path/to/crun\""
446        )),
447    }
448}
449
450/// `cargo libc doctor` runner checks: reports both runners and returns whether
451/// the one `test`/`run` would choose is usable.
452pub fn doctor(simulation: &Simulation) -> Result<bool, String> {
453    let (plan, target) = simulation.plan()?;
454    println!("Kernel simulation for test/run: {}", describe(&plan));
455    let Some(target) = target else {
456        println!("Runner checks skipped: no kernel floor (--distro or --kernel).");
457        return Ok(true);
458    };
459    let image = match &plan {
460        Plan::Container(_, image) => Some(image.clone()),
461        _ => simulation.image.clone().or_else(|| {
462            simulation
463                .distro
464                .as_deref()
465                .and_then(kernel_abi_tools::distro)
466                .map(|d| d.image.to_string())
467        }),
468    };
469    let report = |name: &str, result: &Result<String, String>| match result {
470        Ok(text) if text == UNTESTED => println!("  {name}: not tested: {text}"),
471        Ok(text) => println!("  {name}: ok: {text}"),
472        Err(text) => println!("  {name}: unavailable: {text}"),
473    };
474    println!("Runner checks:");
475    let local = check_local(&target);
476    report("local", &local);
477    let container = check_container(&target, image.as_deref());
478    report("container", &container);
479    let selected = match plan {
480        Plan::Local(_) => local.is_ok(),
481        Plan::Container(..) => container.is_ok(),
482        Plan::Direct => true,
483    };
484    println!(
485        "Selected runner: {}",
486        if selected { "usable" } else { "NOT usable" }
487    );
488    Ok(selected)
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    fn sim(
496        distro: Option<&str>,
497        kernel: Option<&str>,
498        runner: Option<Runner>,
499        image: Option<&str>,
500    ) -> Simulation {
501        Simulation {
502            distro: distro.map(Into::into),
503            kernel: kernel.map(Into::into),
504            runner,
505            image: image.map(Into::into),
506        }
507    }
508
509    #[test]
510    fn plans_follow_the_runner_rules() {
511        let (plan, _) = sim(Some("sles-12-sp5"), None, None, None).plan().unwrap();
512        assert_eq!(
513            plan,
514            Plan::Container(
515                "SUSE Linux Enterprise Server 12 SP5 (Linux 4.12)".into(),
516                "registry.suse.com/suse/sles12sp5:latest".into()
517            )
518        );
519        let (plan, target) = sim(Some("sles-12-sp5"), None, Some(Runner::Local), None)
520            .plan()
521            .unwrap();
522        assert!(
523            matches!(plan, Plan::Local(_)),
524            "local ignores the preset image"
525        );
526        assert_eq!(target.unwrap().kernel.to_string(), "4.12");
527        let (plan, _) = sim(None, Some("3.10"), None, None).plan().unwrap();
528        assert_eq!(plan, Plan::Local("Linux 3.10".into()));
529        let (plan, _) = sim(None, Some("3.10"), None, Some("centos:7"))
530            .plan()
531            .unwrap();
532        assert_eq!(
533            plan,
534            Plan::Container("Linux 3.10".into(), "centos:7".into())
535        );
536        assert_eq!(sim(None, None, None, None).plan().unwrap().0, Plan::Direct);
537        for bad in [
538            sim(None, None, Some(Runner::Local), None),
539            sim(None, None, None, Some("centos:7")),
540            sim(None, Some("3.10"), Some(Runner::Container), None),
541            sim(None, Some("3.10"), Some(Runner::Local), Some("centos:7")),
542            sim(Some("rhel-7"), Some("3.10"), None, None),
543            sim(Some("no-such-distro"), None, None, None),
544        ] {
545            assert!(bad.plan().is_err(), "{bad:?}");
546        }
547    }
548
549    fn needs(entries: &[(&str, &str, bool)]) -> ElfInfo {
550        ElfInfo {
551            machine: 62,
552            interpreter: None,
553            needed: Vec::new(),
554            rpath: Vec::new(),
555            runpath: Vec::new(),
556            versions: entries
557                .iter()
558                .map(|(library, name, weak)| crate::elf::VersionNeed {
559                    library: (*library).into(),
560                    name: (*name).into(),
561                    weak: *weak,
562                })
563                .collect(),
564            definitions: Vec::new(),
565        }
566    }
567
568    #[test]
569    fn host_glibc_compares_required_with_defined_versions() {
570        let host = |library: &str| -> Result<Vec<String>, String> {
571            match library {
572                "libc.so.6" => Ok(vec!["GLIBC_2.2.5".into(), "GLIBC_2.17".into()]),
573                "libm.so.6" => Ok(vec!["GLIBC_2.2.5".into()]),
574                _ => Err(format!("cannot read {library}")),
575            }
576        };
577        let fits = needs(&[
578            ("libc.so.6", "GLIBC_2.17", false),
579            ("libm.so.6", "GLIBC_2.2.5", false),
580        ]);
581        assert_eq!(host_glibc(&fits, host), HostGlibc::Satisfied);
582        let too_new = needs(&[
583            ("libc.so.6", "GLIBC_2.34", false),
584            ("libm.so.6", "GLIBC_2.29", false),
585            ("libc.so.6", "GLIBC_2.38", true),
586            ("libgcc_s.so.1", "GCC_3.0", false),
587        ]);
588        let HostGlibc::Missing(missing) = host_glibc(&too_new, host) else {
589            panic!("expected missing versions")
590        };
591        assert_eq!(missing, ["libc.so.6 GLIBC_2.34", "libm.so.6 GLIBC_2.29"]);
592        assert_eq!(highest(&missing), Some("2.34".parse().unwrap()));
593        let unreadable = needs(&[("libdl.so.2", "GLIBC_2.2.5", false)]);
594        assert!(matches!(
595            host_glibc(&unreadable, host),
596            HostGlibc::Unknown(_)
597        ));
598    }
599
600    #[test]
601    fn search_dirs_follow_the_loader_order_and_expand_origin() {
602        let mut info = needs(&[]);
603        info.rpath.push("/r1:$ORIGIN/r2".into());
604        let dirs = search_dirs(Path::new("/nonexistent/bin/app"), &info).unwrap();
605        assert!(dirs.starts_with(&[PathBuf::from("/r1")]));
606        assert!(dirs[1].ends_with("r2"), "{dirs:?}");
607        // DT_RUNPATH disables DT_RPATH and comes after LD_LIBRARY_PATH.
608        info.runpath.push("/run".into());
609        let dirs = search_dirs(Path::new("/nonexistent/bin/app"), &info).unwrap();
610        assert!(!dirs.contains(&PathBuf::from("/r1")));
611        assert_eq!(dirs.last(), Some(&PathBuf::from("/run")));
612        info.runpath.push("/x/$LIB".into());
613        assert!(search_dirs(Path::new("/nonexistent/bin/app"), &info).is_err());
614    }
615
616    #[test]
617    fn finds_the_runtime_in_engine_options() {
618        let words = |s: &str| s.split_whitespace().map(String::from).collect::<Vec<_>>();
619        assert_eq!(
620            runtime_from_engine_args(&words("--log-level warn --runtime /opt/crun")),
621            Some("/opt/crun".into())
622        );
623        assert_eq!(
624            runtime_from_engine_args(&words("--runtime=crun")),
625            Some("crun".into())
626        );
627        assert_eq!(runtime_from_engine_args(&words("--log-level warn")), None);
628    }
629
630    #[test]
631    fn selection_round_trips_through_the_environment() {
632        let mut cmd = Command::new("x");
633        let s = sim(Some("rhel-7"), None, Some(Runner::Local), None);
634        s.set_environment(&mut cmd, "2.17".parse().unwrap());
635        let envs: Vec<_> = cmd
636            .get_envs()
637            .map(|(k, v)| {
638                (
639                    k.to_string_lossy().into_owned(),
640                    v.map(|v| v.to_string_lossy().into_owned()),
641                )
642            })
643            .collect();
644        assert!(envs.contains(&(GLIBC_ENV.into(), Some("2.17".into()))));
645        assert!(envs.contains(&(DISTRO_ENV.into(), Some("rhel-7".into()))));
646        assert!(envs.contains(&(RUNNER_ENV.into(), Some("local".into()))));
647        assert!(
648            envs.contains(&(KERNEL_ENV.into(), None)),
649            "stale values are removed"
650        );
651    }
652}