nextest_runner/cargo_config/
env.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use super::{CargoConfigSource, CargoConfigs, DiscoveredConfig};
5use camino::{Utf8Path, Utf8PathBuf};
6use std::{
7    collections::{BTreeMap, BTreeSet, btree_map::Entry},
8    ffi::OsString,
9    process::Command,
10};
11
12/// Environment variables to set when running tests.
13#[derive(Clone, Debug)]
14pub struct EnvironmentMap {
15    map: BTreeMap<imp::EnvKey, CargoEnvironmentVariable>,
16}
17
18impl EnvironmentMap {
19    /// Creates a new `EnvironmentMap` from the given Cargo configs.
20    pub fn new(configs: &CargoConfigs) -> Self {
21        let env_configs = configs
22            .discovered_configs()
23            .filter_map(|config| match config {
24                DiscoveredConfig::CliOption { config, source }
25                | DiscoveredConfig::File { config, source } => Some((config, source)),
26                DiscoveredConfig::Env => None,
27            })
28            .flat_map(|(config, source)| {
29                let source = match source {
30                    CargoConfigSource::CliOption => None,
31                    CargoConfigSource::File(path) => Some(path.clone()),
32                };
33                config
34                    .env
35                    .clone()
36                    .into_iter()
37                    .map(move |(name, value)| (source.clone(), name, value))
38            });
39
40        let mut map = BTreeMap::<imp::EnvKey, CargoEnvironmentVariable>::new();
41
42        for (source, name, value) in env_configs {
43            match map.entry(imp::EnvKey::from(name.clone())) {
44                Entry::Occupied(mut entry) => {
45                    // Ignore the value lower in precedence, but do look at force and relative if
46                    // they haven't been set already.
47                    let var = entry.get_mut();
48                    if var.force.is_none() && value.force().is_some() {
49                        var.force = value.force();
50                    }
51                    if var.relative.is_none() && value.relative().is_some() {
52                        var.relative = value.relative();
53                    }
54                }
55                Entry::Vacant(entry) => {
56                    let force = value.force();
57                    let relative = value.relative();
58                    let value = value.into_value();
59                    entry.insert(CargoEnvironmentVariable {
60                        source,
61                        name,
62                        value,
63                        force,
64                        relative,
65                    });
66                }
67            }
68        }
69
70        Self { map }
71    }
72
73    #[cfg(test)]
74    pub(crate) fn empty() -> Self {
75        Self {
76            map: BTreeMap::new(),
77        }
78    }
79
80    pub(crate) fn apply_env(&self, command: &mut Command) {
81        #[cfg_attr(not(windows), expect(clippy::useless_conversion))]
82        let existing_keys: BTreeSet<imp::EnvKey> =
83            std::env::vars_os().map(|(k, _v)| k.into()).collect();
84
85        for (name, var) in &self.map {
86            let should_set_value = if existing_keys.contains(name) {
87                var.force.unwrap_or_default()
88            } else {
89                true
90            };
91            if !should_set_value {
92                continue;
93            }
94
95            let value = if var.relative.unwrap_or_default() {
96                let base_path = match &var.source {
97                    Some(source_path) => source_path,
98                    None => unreachable!(
99                        "Cannot use a relative path for environment variable {name:?} \
100                        whose source is not a config file (this should already have been checked)"
101                    ),
102                };
103                relative_dir_for(base_path).map_or_else(
104                    || var.value.clone(),
105                    |rel_dir| rel_dir.join(&var.value).into_string(),
106                )
107            } else {
108                var.value.clone()
109            };
110
111            command.env(name, value);
112        }
113    }
114}
115
116/// An environment variable set in `config.toml`. See
117/// <https://doc.rust-lang.org/cargo/reference/config.html#env>.
118#[derive(Clone, Debug, PartialEq, Eq)]
119pub struct CargoEnvironmentVariable {
120    /// The source `config.toml` file. See
121    /// <https://doc.rust-lang.org/cargo/reference/config.html#hierarchical-structure> for the
122    /// lookup order.
123    pub source: Option<Utf8PathBuf>,
124
125    /// The name of the environment variable to set.
126    pub name: String,
127
128    /// The value of the environment variable to set.
129    pub value: String,
130
131    /// If the environment variable is already set in the environment, it is not reassigned unless
132    /// `force` is set to `true`.
133    ///
134    /// Note: None means false.
135    pub force: Option<bool>,
136
137    /// Interpret the environment variable as a path relative to the directory containing the source
138    /// `config.toml` file.
139    ///
140    /// Note: None means false.
141    pub relative: Option<bool>,
142}
143
144/// Returns the directory against which relative paths are computed for the given config path.
145pub fn relative_dir_for(config_path: &Utf8Path) -> Option<&Utf8Path> {
146    // Need to call parent() twice here, since in Cargo land relative means relative to the *parent*
147    // of the directory the config is in. First parent() gets the directory the config is in, and
148    // the second one gets the parent of that.
149    let relative_dir = config_path.parent()?.parent()?;
150
151    // On Windows, remove the UNC prefix since Cargo does so as well.
152    Some(imp::strip_unc_prefix(relative_dir))
153}
154
155#[cfg(windows)]
156mod imp {
157    use super::*;
158    use std::{borrow::Borrow, cmp, ffi::OsStr, os::windows::prelude::OsStrExt};
159    use windows_sys::Win32::Globalization::{
160        CSTR_EQUAL, CSTR_GREATER_THAN, CSTR_LESS_THAN, CompareStringOrdinal,
161    };
162
163    pub(super) fn strip_unc_prefix(path: &Utf8Path) -> &Utf8Path {
164        dunce::simplified(path.as_std_path())
165            .try_into()
166            .expect("stripping verbatim components from a UTF-8 path should result in a UTF-8 path")
167    }
168
169    // The definition of EnvKey is borrowed from
170    // https://github.com/rust-lang/rust/blob/a24a020e6d926dffe6b472fc647978f92269504e/library/std/src/sys/windows/process.rs.
171
172    #[derive(Clone, Debug, Eq)]
173    #[doc(hidden)]
174    pub(super) struct EnvKey {
175        os_string: OsString,
176        // This stores a UTF-16 encoded string to workaround the mismatch between
177        // Rust's OsString (WTF-8) and the Windows API string type (UTF-16).
178        // Normally converting on every API call is acceptable but here
179        // `c::CompareStringOrdinal` will be called for every use of `==`.
180        utf16: Vec<u16>,
181    }
182
183    // Comparing Windows environment variable keys[1] are behaviourally the
184    // composition of two operations[2]:
185    //
186    // 1. Case-fold both strings. This is done using a language-independent
187    // uppercase mapping that's unique to Windows (albeit based on data from an
188    // older Unicode spec). It only operates on individual UTF-16 code units so
189    // surrogates are left unchanged. This uppercase mapping can potentially change
190    // between Windows versions.
191    //
192    // 2. Perform an ordinal comparison of the strings. A comparison using ordinal
193    // is just a comparison based on the numerical value of each UTF-16 code unit[3].
194    //
195    // Because the case-folding mapping is unique to Windows and not guaranteed to
196    // be stable, we ask the OS to compare the strings for us. This is done by
197    // calling `CompareStringOrdinal`[4] with `bIgnoreCase` set to `TRUE`.
198    //
199    // [1] https://docs.microsoft.com/en-us/dotnet/standard/base-types/best-practices-strings#choosing-a-stringcomparison-member-for-your-method-call
200    // [2] https://docs.microsoft.com/en-us/dotnet/standard/base-types/best-practices-strings#stringtoupper-and-stringtolower
201    // [3] https://docs.microsoft.com/en-us/dotnet/api/system.stringcomparison?view=net-5.0#System_StringComparison_Ordinal
202    // [4] https://docs.microsoft.com/en-us/windows/win32/api/stringapiset/nf-stringapiset-comparestringordinal
203    impl Ord for EnvKey {
204        fn cmp(&self, other: &Self) -> cmp::Ordering {
205            unsafe {
206                let result = CompareStringOrdinal(
207                    self.utf16.as_ptr(),
208                    self.utf16.len() as _,
209                    other.utf16.as_ptr(),
210                    other.utf16.len() as _,
211                    1, /* ignore case */
212                );
213                match result {
214                    CSTR_LESS_THAN => cmp::Ordering::Less,
215                    CSTR_EQUAL => cmp::Ordering::Equal,
216                    CSTR_GREATER_THAN => cmp::Ordering::Greater,
217                    // `CompareStringOrdinal` should never fail so long as the parameters are correct.
218                    _ => panic!(
219                        "comparing environment keys failed: {}",
220                        std::io::Error::last_os_error()
221                    ),
222                }
223            }
224        }
225    }
226    impl PartialOrd for EnvKey {
227        fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
228            Some(self.cmp(other))
229        }
230    }
231    impl PartialEq for EnvKey {
232        fn eq(&self, other: &Self) -> bool {
233            if self.utf16.len() != other.utf16.len() {
234                false
235            } else {
236                self.cmp(other) == cmp::Ordering::Equal
237            }
238        }
239    }
240
241    // Environment variable keys should preserve their original case even though
242    // they are compared using a caseless string mapping.
243    impl From<OsString> for EnvKey {
244        fn from(k: OsString) -> Self {
245            EnvKey {
246                utf16: k.encode_wide().collect(),
247                os_string: k,
248            }
249        }
250    }
251
252    impl From<String> for EnvKey {
253        fn from(k: String) -> Self {
254            OsString::from(k).into()
255        }
256    }
257
258    impl From<EnvKey> for OsString {
259        fn from(k: EnvKey) -> Self {
260            k.os_string
261        }
262    }
263
264    impl Borrow<OsStr> for EnvKey {
265        fn borrow(&self) -> &OsStr {
266            &self.os_string
267        }
268    }
269
270    impl AsRef<OsStr> for EnvKey {
271        fn as_ref(&self) -> &OsStr {
272            &self.os_string
273        }
274    }
275}
276
277#[cfg(not(windows))]
278mod imp {
279    use super::*;
280
281    pub(super) fn strip_unc_prefix(path: &Utf8Path) -> &Utf8Path {
282        path
283    }
284
285    pub(super) type EnvKey = OsString;
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::cargo_config::test_helpers::setup_temp_dir;
292    use std::ffi::OsStr;
293
294    #[test]
295    fn test_env_var_precedence() {
296        let dir = setup_temp_dir().unwrap();
297        let dir_path = Utf8PathBuf::try_from(dir.path().canonicalize().unwrap()).unwrap();
298        let dir_foo_path = dir_path.join("foo");
299        let dir_foo_bar_path = dir_foo_path.join("bar");
300
301        let configs = CargoConfigs::new_with_isolation(
302            &[] as &[&str],
303            &dir_foo_bar_path,
304            &dir_path,
305            Vec::new(),
306        )
307        .unwrap();
308        let env = EnvironmentMap::new(&configs);
309        let var = env
310            .map
311            .get(OsStr::new("SOME_VAR"))
312            .expect("SOME_VAR is specified in test config");
313        assert_eq!(var.value, "foo-bar-config");
314
315        let configs = CargoConfigs::new_with_isolation(
316            ["env.SOME_VAR=\"cli-config\""],
317            &dir_foo_bar_path,
318            &dir_path,
319            Vec::new(),
320        )
321        .unwrap();
322        let env = EnvironmentMap::new(&configs);
323        let var = env
324            .map
325            .get(OsStr::new("SOME_VAR"))
326            .expect("SOME_VAR is specified in test config");
327        assert_eq!(var.value, "cli-config");
328    }
329
330    #[test]
331    fn test_cli_env_var_relative() {
332        let dir = setup_temp_dir().unwrap();
333        let dir_path = Utf8PathBuf::try_from(dir.path().canonicalize().unwrap()).unwrap();
334        let dir_foo_path = dir_path.join("foo");
335        let dir_foo_bar_path = dir_foo_path.join("bar");
336
337        CargoConfigs::new_with_isolation(
338            ["env.SOME_VAR={value = \"path\", relative = true }"],
339            &dir_foo_bar_path,
340            &dir_path,
341            Vec::new(),
342        )
343        .expect_err("CLI configs can't be relative");
344
345        CargoConfigs::new_with_isolation(
346            ["env.SOME_VAR.value=\"path\"", "env.SOME_VAR.relative=true"],
347            &dir_foo_bar_path,
348            &dir_path,
349            Vec::new(),
350        )
351        .expect_err("CLI configs can't be relative");
352    }
353
354    #[test]
355    #[cfg(unix)]
356    fn test_relative_dir_for_unix() {
357        assert_eq!(
358            relative_dir_for("/foo/bar/.cargo/config.toml".as_ref()),
359            Some("/foo/bar".as_ref()),
360        );
361        assert_eq!(
362            relative_dir_for("/foo/bar/.cargo/config".as_ref()),
363            Some("/foo/bar".as_ref()),
364        );
365        assert_eq!(
366            relative_dir_for("/foo/bar/config".as_ref()),
367            Some("/foo".as_ref())
368        );
369        assert_eq!(relative_dir_for("/foo/config".as_ref()), Some("/".as_ref()));
370        assert_eq!(relative_dir_for("/config.toml".as_ref()), None);
371    }
372
373    #[test]
374    #[cfg(windows)]
375    fn test_relative_dir_for_windows() {
376        assert_eq!(
377            relative_dir_for("C:\\foo\\bar\\.cargo\\config.toml".as_ref()),
378            Some("C:\\foo\\bar".as_ref()),
379        );
380        assert_eq!(
381            relative_dir_for("C:\\foo\\bar\\.cargo\\config".as_ref()),
382            Some("C:\\foo\\bar".as_ref()),
383        );
384        assert_eq!(
385            relative_dir_for("C:\\foo\\bar\\config".as_ref()),
386            Some("C:\\foo".as_ref())
387        );
388        assert_eq!(
389            relative_dir_for("C:\\foo\\config".as_ref()),
390            Some("C:\\".as_ref())
391        );
392        assert_eq!(relative_dir_for("C:\\config.toml".as_ref()), None);
393    }
394}