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