Skip to main content

runner_manager_platform/
runner_env.rs

1//! The environment a native runner starts with, beyond what the daemon has.
2//!
3//! A runner inherits the daemon's environment, and a service's environment is
4//! not a login shell's. On macOS launchd starts the agent with
5//! `PATH=/usr/bin:/bin:/usr/sbin:/sbin` and no `LANG`, so a job finds no
6//! Homebrew tool (`actions/cache` falls back from `zstd` to gzip and never
7//! hits), and nothing the operator put in a shell profile reaches it. A classic
8//! runner gets those from the `.env` and `.path` files its `config.sh` writes;
9//! a just-in-time runner never runs `config.sh`.
10//!
11//! Two things fill the gap, applied to every native runner in this order, each
12//! overriding the one before:
13//!
14//! 1. **Platform defaults** ([`platform_defaults`]): on macOS, Homebrew's
15//!    directories ahead of the inherited `PATH` and a UTF-8 `LANG`; on Windows,
16//!    a profile of the runner's own (`USERPROFILE`, `HOME`, `APPDATA`,
17//!    `LOCALAPPDATA`) inside the attempt, so concurrent jobs stop sharing the
18//!    service account's `~/.bun`, npm cache and pnpm store. Linux has none.
19//! 2. **The host's `runner.env`** ([`RunnerEnv`]): `KEY=VALUE` lines in the
20//!    configuration directory, edited with `runner-manager host env`. A name
21//!    set here replaces the platform default of the same name outright.
22//!
23//! The per-attempt temporary directory (`TMPDIR`, `TEMP`, `TMP`) is applied
24//! last and cannot be overridden, and neither can GitHub's
25//! `ACTIONS_RUNNER_INPUT_*` inputs, one of which carries the JIT
26//! configuration: [`RESERVED_NAMES`] and [`RESERVED_PREFIX`].
27//!
28//! # Not a secret store
29//!
30//! The file is plain text in the configuration directory and every runner's
31//! environment can read it back. Nothing here logs or prints a value except
32//! `host env show`, which exists to show them; errors name a line or a
33//! variable, never what it was set to.
34
35use std::ffi::{OsStr, OsString};
36use std::fs;
37use std::io::{self, Write as _};
38use std::path::{Path, PathBuf};
39
40/// The file's name inside the configuration directory.
41pub const RUNNER_ENV_FILE: &str = "runner.env";
42
43/// Names `runner.env` may not set, compared without regard to case: the
44/// temporary directory is per attempt, and cleanup and isolation rely on it.
45pub const RESERVED_NAMES: [&str; 3] = ["TMPDIR", "TEMP", "TMP"];
46
47/// The prefix of every name `runner.env` may not set: the listener reads each
48/// such variable as a command-line input, including the JIT configuration
49/// that registers it.
50pub const RESERVED_PREFIX: &str = "ACTIONS_RUNNER_INPUT_";
51
52/// macOS directories put ahead of the inherited `PATH`, when they exist and
53/// are not on it already: Apple Silicon Homebrew, then Intel Homebrew.
54pub const MACOS_TOOL_DIRECTORIES: [&str; 3] =
55    ["/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin"];
56
57/// The `PATH` launchd gives a service, kept behind Homebrew's directories when
58/// the daemon has no `PATH` of its own.
59pub const MACOS_SYSTEM_PATH: &str = "/usr/bin:/bin:/usr/sbin:/sbin";
60
61/// The `LANG` a macOS runner gets when the daemon has none. It is what
62/// `config.sh` would have recorded from a default macOS terminal, and without a
63/// UTF-8 locale Ruby, CocoaPods and Python mis-handle non-ASCII text.
64pub const MACOS_DEFAULT_LANG: &str = "en_US.UTF-8";
65
66/// The `runner.env` path for a configuration directory.
67#[must_use]
68pub fn path_in(config_dir: &Path) -> PathBuf {
69    config_dir.join(RUNNER_ENV_FILE)
70}
71
72/// Why a `runner.env` line or assignment was refused, or the file could not
73/// be read or written. No variant carries a value.
74#[derive(Debug, thiserror::Error)]
75pub enum RunnerEnvError {
76    #[error("{}is not KEY=VALUE", at(*.line))]
77    NotAnAssignment { line: Option<usize> },
78    #[error(
79        "{}the name before `=` is not a valid environment variable name; use letters, \
80         digits and `_`, not starting with a digit",
81        at(*.line)
82    )]
83    InvalidName { line: Option<usize> },
84    #[error(
85        "{}`{name}` is set by runner-manager for every runner and cannot be changed",
86        at(*.line)
87    )]
88    Reserved { line: Option<usize>, name: String },
89    #[error("line {line} sets `{name}` a second time")]
90    Duplicate { line: usize, name: String },
91    #[error(
92        "{}the value of `{name}` contains a line break or a NUL character, which runner.env \
93         cannot hold",
94        at(*.line)
95    )]
96    LineBreak { line: Option<usize>, name: String },
97    #[error("cannot {action} {}: {source}", path.display())]
98    Io {
99        action: &'static str,
100        path: PathBuf,
101        #[source]
102        source: io::Error,
103    },
104}
105
106fn at(line: Option<usize>) -> String {
107    line.map_or_else(String::new, |line| format!("line {line}: "))
108}
109
110/// One line of the file. Comments and blank lines are kept so that
111/// `host env set` and `unset` do not discard what an operator wrote by hand.
112#[derive(Debug, Clone, PartialEq, Eq)]
113enum Line {
114    Text(String),
115    Entry { name: String, value: String },
116}
117
118/// The parsed `runner.env`: ordered, one entry per name.
119#[derive(Debug, Clone, Default, PartialEq, Eq)]
120pub struct RunnerEnv {
121    lines: Vec<Line>,
122}
123
124impl RunnerEnv {
125    /// Parses the file's text. Blank lines and lines starting with `#` are
126    /// kept as text; every other line must be `NAME=VALUE`. The value is
127    /// everything after the first `=`, verbatim: no quoting, no expansion.
128    ///
129    /// # Errors
130    /// The first line that is not an assignment, has an invalid or reserved
131    /// name, or repeats a name.
132    pub fn parse(text: &str) -> Result<Self, RunnerEnvError> {
133        let mut env = Self::default();
134        for (index, raw) in text.lines().enumerate() {
135            let line = index + 1;
136            let raw = raw.strip_suffix('\r').unwrap_or(raw);
137            let trimmed = raw.trim_start();
138            if trimmed.is_empty() || trimmed.starts_with('#') {
139                env.lines.push(Line::Text(raw.to_owned()));
140                continue;
141            }
142            let (name, value) = trimmed
143                .split_once('=')
144                .ok_or(RunnerEnvError::NotAnAssignment { line: Some(line) })?;
145            let name = name.trim_end();
146            validate_name(name, Some(line))?;
147            // A NUL cannot reach a process environment: the launch would fail
148            // with nothing naming this file as the cause.
149            if value.contains('\0') {
150                return Err(RunnerEnvError::LineBreak {
151                    line: Some(line),
152                    name: name.to_owned(),
153                });
154            }
155            if env.get(name).is_some() {
156                return Err(RunnerEnvError::Duplicate {
157                    line,
158                    name: name.to_owned(),
159                });
160            }
161            env.lines.push(Line::Entry {
162                name: name.to_owned(),
163                value: value.to_owned(),
164            });
165        }
166        Ok(env)
167    }
168
169    /// Reads `path`; a file that does not exist is an empty environment.
170    ///
171    /// # Errors
172    /// [`RunnerEnvError::Io`] when the file exists and cannot be read, and the
173    /// [`Self::parse`] errors.
174    pub fn load(path: &Path) -> Result<Self, RunnerEnvError> {
175        match fs::read_to_string(path) {
176            Ok(text) => Self::parse(&text),
177            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
178            Err(source) => Err(RunnerEnvError::Io {
179                action: "read",
180                path: path.to_path_buf(),
181                source,
182            }),
183        }
184    }
185
186    /// Writes the file atomically: a reader sees the old file or the new one,
187    /// never a partial one. Created owner-only where the platform has modes,
188    /// since an operator may put something sensitive here regardless.
189    ///
190    /// On Unix the replacement keeps the existing file's owner and mode, and a
191    /// new file takes its directory's owner, best effort: the daemon may run as
192    /// another account than this command (a `sudo` edit of a user agent's
193    /// file), and a file it cannot read stops every native launch.
194    ///
195    /// # Errors
196    /// [`RunnerEnvError::Io`].
197    pub fn save(&self, path: &Path) -> Result<(), RunnerEnvError> {
198        let failed = |action, source| RunnerEnvError::Io {
199            action,
200            path: path.to_path_buf(),
201            source,
202        };
203        let parent = path.parent().unwrap_or_else(|| Path::new("."));
204        fs::create_dir_all(parent).map_err(|source| failed("create the directory of", source))?;
205        let mut temporary =
206            tempfile::NamedTempFile::new_in(parent).map_err(|source| failed("write", source))?;
207        temporary
208            .write_all(self.render().as_bytes())
209            .and_then(|()| temporary.as_file().sync_all())
210            .map_err(|source| failed("write", source))?;
211        #[cfg(unix)]
212        {
213            use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
214            let existing = fs::metadata(path).ok();
215            if let Some(owner) = existing.clone().or_else(|| fs::metadata(parent).ok()) {
216                // Fails without privilege unless the owner is already this
217                // account, which is the case this exists for.
218                let _ = std::os::unix::fs::chown(
219                    temporary.path(),
220                    Some(owner.uid()),
221                    Some(owner.gid()),
222                );
223            }
224            if let Some(existing) = existing {
225                let mode = existing.permissions().mode() & 0o777;
226                temporary
227                    .as_file()
228                    .set_permissions(fs::Permissions::from_mode(mode))
229                    .map_err(|source| failed("write", source))?;
230            }
231        }
232        temporary
233            .persist(path)
234            .map(|_| ())
235            .map_err(|error| failed("replace", error.error))
236    }
237
238    /// The file's text.
239    fn render(&self) -> String {
240        let mut text = String::new();
241        for line in &self.lines {
242            match line {
243                Line::Text(raw) => text.push_str(raw),
244                Line::Entry { name, value } => {
245                    text.push_str(name);
246                    text.push('=');
247                    text.push_str(value);
248                }
249            }
250            text.push('\n');
251        }
252        text
253    }
254
255    /// Sets `name`, replacing its value in place if it is already set.
256    /// Returns whether it was already set.
257    ///
258    /// # Errors
259    /// An invalid or reserved name, or a value with a line break.
260    pub fn set(&mut self, name: &str, value: &str) -> Result<bool, RunnerEnvError> {
261        validate_name(name, None)?;
262        if value.contains(['\n', '\r', '\0']) {
263            return Err(RunnerEnvError::LineBreak {
264                line: None,
265                name: name.to_owned(),
266            });
267        }
268        for line in &mut self.lines {
269            if let Line::Entry {
270                name: existing,
271                value: current,
272            } = line
273                && same_name(existing, name)
274            {
275                *current = value.to_owned();
276                return Ok(true);
277            }
278        }
279        self.lines.push(Line::Entry {
280            name: name.to_owned(),
281            value: value.to_owned(),
282        });
283        Ok(false)
284    }
285
286    /// Removes `name`. Returns whether it was set.
287    pub fn unset(&mut self, name: &str) -> bool {
288        let before = self.lines.len();
289        self.lines.retain(
290            |line| !matches!(line, Line::Entry { name: existing, .. } if same_name(existing, name)),
291        );
292        self.lines.len() != before
293    }
294
295    /// The value of `name`, if set.
296    #[must_use]
297    pub fn get(&self, name: &str) -> Option<&str> {
298        self.entries()
299            .find(|(existing, _)| same_name(existing, name))
300            .map(|(_, value)| value)
301    }
302
303    /// Every `(name, value)`, in file order.
304    pub fn entries(&self) -> impl Iterator<Item = (&str, &str)> {
305        self.lines.iter().filter_map(|line| match line {
306            Line::Entry { name, value } => Some((name.as_str(), value.as_str())),
307            Line::Text(_) => None,
308        })
309    }
310
311    /// How many variables are set.
312    #[must_use]
313    pub fn len(&self) -> usize {
314        self.entries().count()
315    }
316
317    /// Whether no variable is set.
318    #[must_use]
319    pub fn is_empty(&self) -> bool {
320        self.len() == 0
321    }
322}
323
324/// Splits a `NAME=VALUE` command-line argument.
325///
326/// # Errors
327/// [`RunnerEnvError::NotAnAssignment`] without an `=`.
328pub fn split_assignment(raw: &str) -> Result<(&str, &str), RunnerEnvError> {
329    raw.split_once('=')
330        .ok_or(RunnerEnvError::NotAnAssignment { line: None })
331}
332
333/// Whether `name` is one this file may set: a portable environment variable
334/// name that is not [reserved](RESERVED_NAMES).
335fn validate_name(name: &str, line: Option<usize>) -> Result<(), RunnerEnvError> {
336    let mut chars = name.chars();
337    let valid = chars
338        .next()
339        .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
340        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
341    if !valid {
342        return Err(RunnerEnvError::InvalidName { line });
343    }
344    if is_reserved(name) {
345        return Err(RunnerEnvError::Reserved {
346            line,
347            name: name.to_owned(),
348        });
349    }
350    Ok(())
351}
352
353/// Whether `name` is reserved, without regard to case: Windows treats `Tmp`
354/// and `TMP` as one variable.
355fn is_reserved(name: &str) -> bool {
356    let upper = name.to_ascii_uppercase();
357    upper.starts_with(RESERVED_PREFIX) || RESERVED_NAMES.contains(&upper.as_str())
358}
359
360/// Whether two names are the same variable on this platform.
361pub(crate) fn same_name(a: &str, b: &str) -> bool {
362    if cfg!(windows) {
363        a.eq_ignore_ascii_case(b)
364    } else {
365        a == b
366    }
367}
368
369// ---------------------------------------------------------------------------
370// Platform defaults
371// ---------------------------------------------------------------------------
372
373/// Which platform's defaults to compute. A parameter rather than a `cfg` so
374/// that every leg of CI tests every platform's rules.
375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376pub enum RunnerPlatform {
377    Windows,
378    MacOs,
379    Linux,
380}
381
382impl RunnerPlatform {
383    /// The platform this binary was built for.
384    #[must_use]
385    pub const fn current() -> Self {
386        if cfg!(windows) {
387            Self::Windows
388        } else if cfg!(target_os = "macos") {
389            Self::MacOs
390        } else {
391            Self::Linux
392        }
393    }
394}
395
396/// What the daemon's own environment holds, as far as the defaults care.
397#[derive(Debug, Clone, Default)]
398pub struct Inherited {
399    pub path: Option<OsString>,
400    /// Whether `LANG` or `LC_ALL` is set.
401    pub has_locale: bool,
402}
403
404impl Inherited {
405    /// This process's environment.
406    #[must_use]
407    pub fn current() -> Self {
408        Self {
409            path: std::env::var_os("PATH"),
410            has_locale: std::env::var_os("LANG").is_some() || std::env::var_os("LC_ALL").is_some(),
411        }
412    }
413}
414
415/// The directory inside an attempt that is its runner's profile on Windows.
416pub const RUNNER_HOME_DIR: &str = "home";
417
418/// The variables [`platform_defaults`] sets on Windows, in order: the profile
419/// directory twice, then its roaming and local application data.
420pub const WINDOWS_PROFILE_VARIABLES: [&str; 4] = ["USERPROFILE", "HOME", "APPDATA", "LOCALAPPDATA"];
421
422/// The [`platform_defaults`] that name a directory the caller must create.
423/// Creating them creates the profile directory above them too.
424pub const DIRECTORY_VARIABLES: [&str; 2] = ["APPDATA", "LOCALAPPDATA"];
425
426/// The variables `platform` sets for a runner whose attempt directory is
427/// `runtime`, before `runner.env` is applied.
428///
429/// - **macOS:** `PATH` with each of [`MACOS_TOOL_DIRECTORIES`] that exists
430///   (`exists`) and is not already listed put in front, when any is; `LANG`
431///   as [`MACOS_DEFAULT_LANG`] when the daemon has no locale.
432/// - **Windows:** `USERPROFILE` and `HOME` at `<runtime>\home`, and `APPDATA`
433///   and `LOCALAPPDATA` under it, as a logged-in profile lays them out. The
434///   directories are the caller's to create. Per attempt rather than shared,
435///   so one job's caches and configuration cannot reach the next job, and
436///   they go when the attempt's directory goes. .NET's known-folder APIs
437///   (`Environment.GetFolderPath`) ask Windows rather than these variables
438///   and still answer with the service account's profile.
439/// - **Linux:** nothing.
440#[must_use]
441pub fn platform_defaults(
442    platform: RunnerPlatform,
443    runtime: &Path,
444    inherited: &Inherited,
445    exists: impl Fn(&Path) -> bool,
446) -> Vec<(&'static str, OsString)> {
447    match platform {
448        RunnerPlatform::Linux => Vec::new(),
449        RunnerPlatform::Windows => {
450            let home = runtime.join(RUNNER_HOME_DIR);
451            let app_data = home.join("AppData");
452            let [user_profile, home_name, roaming, local] = WINDOWS_PROFILE_VARIABLES;
453            vec![
454                (user_profile, home.clone().into_os_string()),
455                (home_name, home.into_os_string()),
456                (roaming, app_data.join("Roaming").into_os_string()),
457                (local, app_data.join("Local").into_os_string()),
458            ]
459        }
460        RunnerPlatform::MacOs => {
461            let mut defaults = Vec::new();
462            let listed: Vec<&OsStr> = inherited
463                .path
464                .as_deref()
465                .map(|path| split_colon_path(path))
466                .unwrap_or_default();
467            let missing: Vec<&str> = MACOS_TOOL_DIRECTORIES
468                .into_iter()
469                .filter(|dir| exists(Path::new(dir)) && !listed.contains(&OsStr::new(dir)))
470                .collect();
471            if !missing.is_empty() {
472                let mut path = OsString::from(missing.join(":"));
473                path.push(":");
474                // With no `PATH` at all a program search falls back to the
475                // system's own directories; a `PATH` naming only Homebrew would
476                // take those away, so they are spelled out instead.
477                match inherited.path.as_deref().filter(|p| !p.is_empty()) {
478                    Some(inherited) => path.push(inherited),
479                    None => path.push(MACOS_SYSTEM_PATH),
480                }
481                defaults.push(("PATH", path));
482            }
483            if !inherited.has_locale {
484                defaults.push(("LANG", OsString::from(MACOS_DEFAULT_LANG)));
485            }
486            defaults
487        }
488    }
489}
490
491/// The entries of a `:`-separated `PATH`, as bytes on Unix and as text
492/// elsewhere (where this is only ever exercised by tests).
493fn split_colon_path(path: &OsStr) -> Vec<&OsStr> {
494    #[cfg(unix)]
495    {
496        use std::os::unix::ffi::OsStrExt;
497        path.as_bytes()
498            .split(|byte| *byte == b':')
499            .map(OsStr::from_bytes)
500            .collect()
501    }
502    #[cfg(not(unix))]
503    {
504        path.to_str()
505            .map(|text| text.split(':').map(OsStr::new).collect())
506            .unwrap_or_default()
507    }
508}
509
510/// The variables a native runner starts with on top of the daemon's
511/// environment, in the order they must be applied: the platform defaults, then
512/// the dependency caches (`crate::dependency_cache`), then `file`. Applied in
513/// order the last value for a name wins, as it does for `Command::env`, so
514/// `file` overrides a cache or a default of the same name.
515#[must_use]
516pub fn runner_environment(
517    defaults: Vec<(&'static str, OsString)>,
518    caches: Vec<(&'static str, OsString)>,
519    file: &RunnerEnv,
520) -> Vec<(OsString, OsString)> {
521    defaults
522        .into_iter()
523        .chain(caches)
524        .map(|(name, value)| (OsString::from(name), value))
525        .chain(
526            file.entries()
527                .map(|(name, value)| (OsString::from(name), OsString::from(value))),
528        )
529        .collect()
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    #[test]
537    fn a_file_parses_keeps_comments_and_round_trips() {
538        let text = "# caches off the boot volume\n\nELECTRON_CACHE=/Volumes/NVME/cache/electron\n\
539                    npm_config_cache=/Volumes/NVME/cache/npm\nEMPTY=\nURL=https://x/?a=b\n";
540        let env = RunnerEnv::parse(text).unwrap();
541        assert_eq!(
542            env.entries().collect::<Vec<_>>(),
543            [
544                ("ELECTRON_CACHE", "/Volumes/NVME/cache/electron"),
545                ("npm_config_cache", "/Volumes/NVME/cache/npm"),
546                ("EMPTY", ""),
547                ("URL", "https://x/?a=b"),
548            ]
549        );
550        assert_eq!(env.render(), text);
551        assert_eq!(
552            RunnerEnv::parse("A=1\r\nB=2\r\n").unwrap().get("B"),
553            Some("2")
554        );
555    }
556
557    #[test]
558    fn a_bad_line_is_named_by_number_and_never_echoed() {
559        let secret = "ghp_notarealtokenbutshapedlikeone";
560        for (text, expected) in [
561            (format!("A=1\n{secret}\n"), "line 2: is not KEY=VALUE"),
562            (format!("A=1\n1BAD={secret}\n"), "line 2: the name"),
563            (
564                format!("TMP={secret}\n"),
565                "line 1: `TMP` is set by runner-manager",
566            ),
567            (
568                format!("actions_runner_input_url={secret}\n"),
569                "`actions_runner_input_url` is set by runner-manager",
570            ),
571            (
572                format!("A=1\nA={secret}\n"),
573                "line 2 sets `A` a second time",
574            ),
575            (
576                format!("A=1\nB={secret}\0\n"),
577                "line 2: the value of `B` contains a line break or a NUL",
578            ),
579        ] {
580            let error = RunnerEnv::parse(&text).unwrap_err().to_string();
581            assert!(error.contains(expected), "{error:?} lacks {expected:?}");
582            assert!(!error.contains(secret), "an error echoed a value: {error}");
583        }
584    }
585
586    #[test]
587    fn reserved_names_cover_the_temporary_directory_and_every_runner_input() {
588        for name in [
589            "TMPDIR",
590            "TEMP",
591            "TMP",
592            "Tmp",
593            "ACTIONS_RUNNER_INPUT_JITCONFIG",
594            "ACTIONS_RUNNER_INPUT_URL",
595        ] {
596            assert!(is_reserved(name), "{name}");
597        }
598        for name in ["TMPX", "PATH", "ACTIONS_RUNNER_HOOK_JOB_STARTED", "HOME"] {
599            assert!(!is_reserved(name), "{name}");
600        }
601    }
602
603    #[test]
604    fn set_replaces_in_place_and_unset_removes_only_the_entry() {
605        let mut env = RunnerEnv::parse("# keep me\nA=1\nB=2\n").unwrap();
606        assert!(env.set("A", "one").unwrap());
607        assert!(!env.set("C", "3").unwrap());
608        assert!(env.unset("B"));
609        assert!(!env.unset("B"));
610        assert_eq!(env.render(), "# keep me\nA=one\nC=3\n");
611        assert!(matches!(
612            env.set("D", "two\nlines"),
613            Err(RunnerEnvError::LineBreak { .. })
614        ));
615        assert!(matches!(
616            env.set("TEMP", "x"),
617            Err(RunnerEnvError::Reserved { .. })
618        ));
619        assert!(matches!(
620            env.set("NOT-A-NAME", "x"),
621            Err(RunnerEnvError::InvalidName { .. })
622        ));
623    }
624
625    #[cfg(unix)]
626    #[test]
627    fn saving_keeps_the_mode_of_the_file_it_replaces() {
628        use std::os::unix::fs::PermissionsExt as _;
629        let directory = tempfile::tempdir().unwrap();
630        let path = path_in(directory.path());
631        let mut env = RunnerEnv::default();
632        env.set("A", "1").unwrap();
633        env.save(&path).unwrap();
634        let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
635        assert_eq!(mode(&path), 0o600, "a new file is owner-only");
636
637        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
638        env.set("B", "2").unwrap();
639        env.save(&path).unwrap();
640        assert_eq!(mode(&path), 0o644, "the replacement reset the file's mode");
641    }
642
643    #[test]
644    fn a_missing_file_is_empty_and_a_saved_file_loads_back() {
645        let directory = tempfile::tempdir().unwrap();
646        let path = path_in(directory.path());
647        assert!(RunnerEnv::load(&path).unwrap().is_empty());
648
649        let mut env = RunnerEnv::default();
650        env.set("npm_config_cache", "/cache/npm").unwrap();
651        env.save(&path).unwrap();
652        assert_eq!(RunnerEnv::load(&path).unwrap(), env);
653        assert_eq!(
654            fs::read_to_string(&path).unwrap(),
655            "npm_config_cache=/cache/npm\n"
656        );
657    }
658
659    fn defaults(platform: RunnerPlatform, inherited: &Inherited) -> Vec<(&'static str, OsString)> {
660        platform_defaults(platform, Path::new("/rman/a1"), inherited, |dir| {
661            dir != Path::new("/usr/local/bin")
662        })
663    }
664
665    #[test]
666    fn macos_puts_existing_homebrew_directories_ahead_of_the_service_path() {
667        let launchd = Inherited {
668            path: Some("/usr/bin:/bin:/usr/sbin:/sbin".into()),
669            has_locale: false,
670        };
671        assert_eq!(
672            defaults(RunnerPlatform::MacOs, &launchd),
673            [
674                (
675                    "PATH",
676                    OsString::from(
677                        "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin"
678                    )
679                ),
680                ("LANG", OsString::from(MACOS_DEFAULT_LANG)),
681            ]
682        );
683
684        // A directory already listed is not added twice, and a daemon with a
685        // locale keeps it.
686        let login = Inherited {
687            path: Some("/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin".into()),
688            has_locale: true,
689        };
690        assert!(defaults(RunnerPlatform::MacOs, &login).is_empty());
691
692        // A daemon with no PATH keeps the system directories behind Homebrew's.
693        let bare = Inherited {
694            path: None,
695            has_locale: true,
696        };
697        assert_eq!(
698            defaults(RunnerPlatform::MacOs, &bare),
699            [(
700                "PATH",
701                OsString::from(format!(
702                    "/opt/homebrew/bin:/opt/homebrew/sbin:{MACOS_SYSTEM_PATH}"
703                ))
704            )]
705        );
706    }
707
708    #[test]
709    fn windows_gives_each_runner_a_profile_inside_its_attempt() {
710        let windows = defaults(RunnerPlatform::Windows, &Inherited::default());
711        let home = Path::new("/rman/a1").join(RUNNER_HOME_DIR);
712        assert_eq!(
713            windows,
714            [
715                ("USERPROFILE", home.clone().into_os_string()),
716                ("HOME", home.clone().into_os_string()),
717                (
718                    "APPDATA",
719                    home.join("AppData").join("Roaming").into_os_string()
720                ),
721                (
722                    "LOCALAPPDATA",
723                    home.join("AppData").join("Local").into_os_string()
724                ),
725            ]
726        );
727        assert!(defaults(RunnerPlatform::Linux, &Inherited::default()).is_empty());
728    }
729
730    #[test]
731    fn runner_env_replaces_a_default_of_the_same_name_and_adds_the_rest() {
732        let file = RunnerEnv::parse("PATH=/custom/bin\nELECTRON_CACHE=/cache/electron\n").unwrap();
733        let defaults = vec![
734            ("PATH", OsString::from("/opt/homebrew/bin:/usr/bin")),
735            ("LANG", OsString::from(MACOS_DEFAULT_LANG)),
736        ];
737        assert_eq!(
738            runner_environment(defaults, Vec::new(), &file),
739            [
740                (
741                    OsString::from("PATH"),
742                    OsString::from("/opt/homebrew/bin:/usr/bin")
743                ),
744                (OsString::from("LANG"), OsString::from(MACOS_DEFAULT_LANG)),
745                (OsString::from("PATH"), OsString::from("/custom/bin")),
746                (
747                    OsString::from("ELECTRON_CACHE"),
748                    OsString::from("/cache/electron")
749                ),
750            ]
751        );
752    }
753
754    /// Platform defaults < dependency caches < `runner.env`: a cache variable
755    /// replaces a default of the same name, and `runner.env` replaces both.
756    #[test]
757    fn caches_sit_between_the_platform_defaults_and_runner_env() {
758        let file = RunnerEnv::parse("npm_config_cache=/operator/npm\n").unwrap();
759        let defaults = vec![("HOME", OsString::from("/attempt/home"))];
760        let caches = vec![
761            ("npm_config_cache", OsString::from("/cache/o/r/npm")),
762            ("HOME", OsString::from("/cache-would-never-set-this")),
763        ];
764        let applied = runner_environment(defaults, caches, &file);
765        let last = |name: &str| {
766            applied
767                .iter()
768                .rev()
769                .find(|(n, _)| n == name)
770                .map(|(_, v)| v.clone())
771        };
772        assert_eq!(last("npm_config_cache"), Some("/operator/npm".into()));
773        assert_eq!(last("HOME"), Some("/cache-would-never-set-this".into()));
774        let position = |value: &str| applied.iter().position(|(_, v)| v == value).unwrap();
775        assert!(position("/attempt/home") < position("/cache/o/r/npm"));
776        assert!(position("/cache/o/r/npm") < position("/operator/npm"));
777    }
778}