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