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/// `file`. Applied in order the last value for a name wins, as it does for
513/// `Command::env`, so `file` overrides a default of the same name.
514#[must_use]
515pub fn runner_environment(
516    defaults: Vec<(&'static str, OsString)>,
517    file: &RunnerEnv,
518) -> Vec<(OsString, OsString)> {
519    defaults
520        .into_iter()
521        .map(|(name, value)| (OsString::from(name), value))
522        .chain(
523            file.entries()
524                .map(|(name, value)| (OsString::from(name), OsString::from(value))),
525        )
526        .collect()
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn a_file_parses_keeps_comments_and_round_trips() {
535        let text = "# caches off the boot volume\n\nELECTRON_CACHE=/Volumes/NVME/cache/electron\n\
536                    npm_config_cache=/Volumes/NVME/cache/npm\nEMPTY=\nURL=https://x/?a=b\n";
537        let env = RunnerEnv::parse(text).unwrap();
538        assert_eq!(
539            env.entries().collect::<Vec<_>>(),
540            [
541                ("ELECTRON_CACHE", "/Volumes/NVME/cache/electron"),
542                ("npm_config_cache", "/Volumes/NVME/cache/npm"),
543                ("EMPTY", ""),
544                ("URL", "https://x/?a=b"),
545            ]
546        );
547        assert_eq!(env.render(), text);
548        assert_eq!(
549            RunnerEnv::parse("A=1\r\nB=2\r\n").unwrap().get("B"),
550            Some("2")
551        );
552    }
553
554    #[test]
555    fn a_bad_line_is_named_by_number_and_never_echoed() {
556        let secret = "ghp_notarealtokenbutshapedlikeone";
557        for (text, expected) in [
558            (format!("A=1\n{secret}\n"), "line 2: is not KEY=VALUE"),
559            (format!("A=1\n1BAD={secret}\n"), "line 2: the name"),
560            (
561                format!("TMP={secret}\n"),
562                "line 1: `TMP` is set by runner-manager",
563            ),
564            (
565                format!("actions_runner_input_url={secret}\n"),
566                "`actions_runner_input_url` is set by runner-manager",
567            ),
568            (
569                format!("A=1\nA={secret}\n"),
570                "line 2 sets `A` a second time",
571            ),
572            (
573                format!("A=1\nB={secret}\0\n"),
574                "line 2: the value of `B` contains a line break or a NUL",
575            ),
576        ] {
577            let error = RunnerEnv::parse(&text).unwrap_err().to_string();
578            assert!(error.contains(expected), "{error:?} lacks {expected:?}");
579            assert!(!error.contains(secret), "an error echoed a value: {error}");
580        }
581    }
582
583    #[test]
584    fn reserved_names_cover_the_temporary_directory_and_every_runner_input() {
585        for name in [
586            "TMPDIR",
587            "TEMP",
588            "TMP",
589            "Tmp",
590            "ACTIONS_RUNNER_INPUT_JITCONFIG",
591            "ACTIONS_RUNNER_INPUT_URL",
592        ] {
593            assert!(is_reserved(name), "{name}");
594        }
595        for name in ["TMPX", "PATH", "ACTIONS_RUNNER_HOOK_JOB_STARTED", "HOME"] {
596            assert!(!is_reserved(name), "{name}");
597        }
598    }
599
600    #[test]
601    fn set_replaces_in_place_and_unset_removes_only_the_entry() {
602        let mut env = RunnerEnv::parse("# keep me\nA=1\nB=2\n").unwrap();
603        assert!(env.set("A", "one").unwrap());
604        assert!(!env.set("C", "3").unwrap());
605        assert!(env.unset("B"));
606        assert!(!env.unset("B"));
607        assert_eq!(env.render(), "# keep me\nA=one\nC=3\n");
608        assert!(matches!(
609            env.set("D", "two\nlines"),
610            Err(RunnerEnvError::LineBreak { .. })
611        ));
612        assert!(matches!(
613            env.set("TEMP", "x"),
614            Err(RunnerEnvError::Reserved { .. })
615        ));
616        assert!(matches!(
617            env.set("NOT-A-NAME", "x"),
618            Err(RunnerEnvError::InvalidName { .. })
619        ));
620    }
621
622    #[cfg(unix)]
623    #[test]
624    fn saving_keeps_the_mode_of_the_file_it_replaces() {
625        use std::os::unix::fs::PermissionsExt as _;
626        let directory = tempfile::tempdir().unwrap();
627        let path = path_in(directory.path());
628        let mut env = RunnerEnv::default();
629        env.set("A", "1").unwrap();
630        env.save(&path).unwrap();
631        let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
632        assert_eq!(mode(&path), 0o600, "a new file is owner-only");
633
634        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
635        env.set("B", "2").unwrap();
636        env.save(&path).unwrap();
637        assert_eq!(mode(&path), 0o644, "the replacement reset the file's mode");
638    }
639
640    #[test]
641    fn a_missing_file_is_empty_and_a_saved_file_loads_back() {
642        let directory = tempfile::tempdir().unwrap();
643        let path = path_in(directory.path());
644        assert!(RunnerEnv::load(&path).unwrap().is_empty());
645
646        let mut env = RunnerEnv::default();
647        env.set("npm_config_cache", "/cache/npm").unwrap();
648        env.save(&path).unwrap();
649        assert_eq!(RunnerEnv::load(&path).unwrap(), env);
650        assert_eq!(
651            fs::read_to_string(&path).unwrap(),
652            "npm_config_cache=/cache/npm\n"
653        );
654    }
655
656    fn defaults(platform: RunnerPlatform, inherited: &Inherited) -> Vec<(&'static str, OsString)> {
657        platform_defaults(platform, Path::new("/rman/a1"), inherited, |dir| {
658            dir != Path::new("/usr/local/bin")
659        })
660    }
661
662    #[test]
663    fn macos_puts_existing_homebrew_directories_ahead_of_the_service_path() {
664        let launchd = Inherited {
665            path: Some("/usr/bin:/bin:/usr/sbin:/sbin".into()),
666            has_locale: false,
667        };
668        assert_eq!(
669            defaults(RunnerPlatform::MacOs, &launchd),
670            [
671                (
672                    "PATH",
673                    OsString::from(
674                        "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin"
675                    )
676                ),
677                ("LANG", OsString::from(MACOS_DEFAULT_LANG)),
678            ]
679        );
680
681        // A directory already listed is not added twice, and a daemon with a
682        // locale keeps it.
683        let login = Inherited {
684            path: Some("/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin".into()),
685            has_locale: true,
686        };
687        assert!(defaults(RunnerPlatform::MacOs, &login).is_empty());
688
689        // A daemon with no PATH keeps the system directories behind Homebrew's.
690        let bare = Inherited {
691            path: None,
692            has_locale: true,
693        };
694        assert_eq!(
695            defaults(RunnerPlatform::MacOs, &bare),
696            [(
697                "PATH",
698                OsString::from(format!(
699                    "/opt/homebrew/bin:/opt/homebrew/sbin:{MACOS_SYSTEM_PATH}"
700                ))
701            )]
702        );
703    }
704
705    #[test]
706    fn windows_gives_each_runner_a_profile_inside_its_attempt() {
707        let windows = defaults(RunnerPlatform::Windows, &Inherited::default());
708        let home = Path::new("/rman/a1").join(RUNNER_HOME_DIR);
709        assert_eq!(
710            windows,
711            [
712                ("USERPROFILE", home.clone().into_os_string()),
713                ("HOME", home.clone().into_os_string()),
714                (
715                    "APPDATA",
716                    home.join("AppData").join("Roaming").into_os_string()
717                ),
718                (
719                    "LOCALAPPDATA",
720                    home.join("AppData").join("Local").into_os_string()
721                ),
722            ]
723        );
724        assert!(defaults(RunnerPlatform::Linux, &Inherited::default()).is_empty());
725    }
726
727    #[test]
728    fn runner_env_replaces_a_default_of_the_same_name_and_adds_the_rest() {
729        let file = RunnerEnv::parse("PATH=/custom/bin\nELECTRON_CACHE=/cache/electron\n").unwrap();
730        let defaults = vec![
731            ("PATH", OsString::from("/opt/homebrew/bin:/usr/bin")),
732            ("LANG", OsString::from(MACOS_DEFAULT_LANG)),
733        ];
734        assert_eq!(
735            runner_environment(defaults, &file),
736            [
737                (
738                    OsString::from("PATH"),
739                    OsString::from("/opt/homebrew/bin:/usr/bin")
740                ),
741                (OsString::from("LANG"), OsString::from(MACOS_DEFAULT_LANG)),
742                (OsString::from("PATH"), OsString::from("/custom/bin")),
743                (
744                    OsString::from("ELECTRON_CACHE"),
745                    OsString::from("/cache/electron")
746                ),
747            ]
748        );
749    }
750}