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    /// Whether `HOME` names a directory this process may write.
431    ///
432    /// A Linux boot service runs as `root` with `HOME=/root`, which
433    /// `ProtectSystem=strict` makes read-only and `ProtectHome=tmpfs` empties,
434    /// so a job's `~/.bun`, `~/.docker` or global npm install has nowhere to go.
435    pub home_writable: bool,
436}
437
438impl Inherited {
439    /// This process's environment.
440    #[must_use]
441    pub fn current() -> Self {
442        let mut inherited = Self::from_env(|name| std::env::var_os(name));
443        inherited.home_writable = std::env::var_os("HOME").is_some_and(|home| writable(&home));
444        inherited
445    }
446
447    /// The environment `var` reads, one variable at a time.
448    fn from_env(var: impl Fn(&str) -> Option<OsString>) -> Self {
449        Self {
450            path: var("PATH"),
451            has_locale: var("LANG").is_some() || var("LC_ALL").is_some(),
452            has_powershell_telemetry_optout: var(POWERSHELL_TELEMETRY_OPTOUT)
453                .is_some_and(|value| !value.is_empty()),
454            home_writable: false,
455        }
456    }
457}
458
459/// Whether this process may create entries in `directory`.
460fn writable(directory: &OsStr) -> bool {
461    #[cfg(unix)]
462    {
463        use std::os::unix::ffi::OsStrExt as _;
464        let Ok(path) = std::ffi::CString::new(directory.as_bytes()) else {
465            return false;
466        };
467        // SAFETY: `path` is a valid NUL-terminated string that outlives the
468        // call, and `access` only reads it.
469        unsafe { libc::access(path.as_ptr(), libc::W_OK | libc::X_OK) == 0 }
470    }
471    #[cfg(not(unix))]
472    {
473        Path::new(directory).is_dir()
474    }
475}
476
477/// The directory inside an attempt that is its runner's profile on Windows,
478/// and its home on Linux when the daemon's own cannot be written.
479pub const RUNNER_HOME_DIR: &str = "home";
480
481/// The variables [`platform_defaults`] sets on Windows, in order: the profile
482/// directory twice, then its roaming and local application data.
483pub const WINDOWS_PROFILE_VARIABLES: [&str; 4] = ["USERPROFILE", "HOME", "APPDATA", "LOCALAPPDATA"];
484
485/// The [`platform_defaults`] that name a directory the caller must create.
486/// Creating them creates the profile directory above them too.
487pub const DIRECTORY_VARIABLES: [&str; 3] = ["HOME", "APPDATA", "LOCALAPPDATA"];
488
489/// The variables `platform` sets for a runner whose attempt directory is
490/// `runtime`, before `runner.env` is applied.
491///
492/// - **macOS:** `PATH` with each of [`MACOS_TOOL_DIRECTORIES`] that exists
493///   (`exists`) and is not already listed put in front, when any is; `LANG`
494///   as [`MACOS_DEFAULT_LANG`] when the daemon has no locale.
495/// - **Windows:** `USERPROFILE` and `HOME` at `<runtime>\home`, and `APPDATA`
496///   and `LOCALAPPDATA` under it, as a logged-in profile lays them out. The
497///   directories are the caller's to create. Per attempt rather than shared,
498///   so one job's caches and configuration cannot reach the next job, and
499///   they go when the attempt's directory goes. Most of .NET's known-folder
500///   APIs (`Environment.GetFolderPath`) ask Windows rather than these
501///   variables and still answer with the service account's profile. Local
502///   application data is the exception: measured on Windows 11, it follows
503///   `USERPROFILE`, while roaming application data and the profile itself do
504///   not.
505/// - **Linux:** `HOME` at `<runtime>/home` when the daemon's own `HOME` cannot
506///   be written ([`Inherited::home_writable`]); nothing otherwise. A login
507///   service's `HOME` is the operator's, and stays theirs.
508///
509/// Then, on every platform, [`POWERSHELL_TELEMETRY_OPTOUT`] unless the daemon
510/// sets it.
511#[must_use]
512pub fn platform_defaults(
513    platform: RunnerPlatform,
514    runtime: &Path,
515    inherited: &Inherited,
516    exists: impl Fn(&Path) -> bool,
517) -> Vec<(&'static str, OsString)> {
518    let mut defaults = match platform {
519        RunnerPlatform::Linux if inherited.home_writable => Vec::new(),
520        RunnerPlatform::Linux => vec![("HOME", runtime.join(RUNNER_HOME_DIR).into_os_string())],
521        RunnerPlatform::Windows => {
522            let home = runtime.join(RUNNER_HOME_DIR);
523            let app_data = home.join("AppData");
524            let [user_profile, home_name, roaming, local] = WINDOWS_PROFILE_VARIABLES;
525            vec![
526                (user_profile, home.clone().into_os_string()),
527                (home_name, home.into_os_string()),
528                (roaming, app_data.join("Roaming").into_os_string()),
529                (local, app_data.join("Local").into_os_string()),
530            ]
531        }
532        RunnerPlatform::MacOs => {
533            let mut defaults = Vec::new();
534            let listed: Vec<&OsStr> = inherited
535                .path
536                .as_deref()
537                .map(|path| split_colon_path(path))
538                .unwrap_or_default();
539            let missing: Vec<&str> = MACOS_TOOL_DIRECTORIES
540                .into_iter()
541                .filter(|dir| exists(Path::new(dir)) && !listed.contains(&OsStr::new(dir)))
542                .collect();
543            if !missing.is_empty() {
544                let mut path = OsString::from(missing.join(":"));
545                path.push(":");
546                // With no `PATH` at all a program search falls back to the
547                // system's own directories; a `PATH` naming only Homebrew would
548                // take those away, so they are spelled out instead.
549                match inherited.path.as_deref().filter(|p| !p.is_empty()) {
550                    Some(inherited) => path.push(inherited),
551                    None => path.push(MACOS_SYSTEM_PATH),
552                }
553                defaults.push(("PATH", path));
554            }
555            if !inherited.has_locale {
556                defaults.push(("LANG", OsString::from(MACOS_DEFAULT_LANG)));
557            }
558            defaults
559        }
560    };
561    if !inherited.has_powershell_telemetry_optout {
562        defaults.push((
563            POWERSHELL_TELEMETRY_OPTOUT,
564            OsString::from(POWERSHELL_TELEMETRY_OPTOUT_VALUE),
565        ));
566    }
567    defaults
568}
569
570/// The entries of a `:`-separated `PATH`, as bytes on Unix and as text
571/// elsewhere (where this is only ever exercised by tests).
572fn split_colon_path(path: &OsStr) -> Vec<&OsStr> {
573    #[cfg(unix)]
574    {
575        use std::os::unix::ffi::OsStrExt;
576        path.as_bytes()
577            .split(|byte| *byte == b':')
578            .map(OsStr::from_bytes)
579            .collect()
580    }
581    #[cfg(not(unix))]
582    {
583        path.to_str()
584            .map(|text| text.split(':').map(OsStr::new).collect())
585            .unwrap_or_default()
586    }
587}
588
589/// The variables a native runner starts with on top of the daemon's
590/// environment, in the order they must be applied: the platform defaults, then
591/// the dependency caches (`crate::dependency_cache`), then `file`. Applied in
592/// order the last value for a name wins, as it does for `Command::env`, so
593/// `file` overrides a cache or a default of the same name.
594#[must_use]
595pub fn runner_environment(
596    defaults: Vec<(&'static str, OsString)>,
597    caches: Vec<(&'static str, OsString)>,
598    file: &RunnerEnv,
599) -> Vec<(OsString, OsString)> {
600    defaults
601        .into_iter()
602        .chain(caches)
603        .map(|(name, value)| (OsString::from(name), value))
604        .chain(
605            file.entries()
606                .map(|(name, value)| (OsString::from(name), OsString::from(value))),
607        )
608        .collect()
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    #[test]
616    fn a_file_parses_keeps_comments_and_round_trips() {
617        let text = "# caches off the boot volume\n\nELECTRON_CACHE=/Volumes/NVME/cache/electron\n\
618                    npm_config_cache=/Volumes/NVME/cache/npm\nEMPTY=\nURL=https://x/?a=b\n";
619        let env = RunnerEnv::parse(text).unwrap();
620        assert_eq!(
621            env.entries().collect::<Vec<_>>(),
622            [
623                ("ELECTRON_CACHE", "/Volumes/NVME/cache/electron"),
624                ("npm_config_cache", "/Volumes/NVME/cache/npm"),
625                ("EMPTY", ""),
626                ("URL", "https://x/?a=b"),
627            ]
628        );
629        assert_eq!(env.render(), text);
630        assert_eq!(
631            RunnerEnv::parse("A=1\r\nB=2\r\n").unwrap().get("B"),
632            Some("2")
633        );
634    }
635
636    #[test]
637    fn a_bad_line_is_named_by_number_and_never_echoed() {
638        let secret = "ghp_notarealtokenbutshapedlikeone";
639        for (text, expected) in [
640            (format!("A=1\n{secret}\n"), "line 2: is not KEY=VALUE"),
641            (format!("A=1\n1BAD={secret}\n"), "line 2: the name"),
642            (
643                format!("TMP={secret}\n"),
644                "line 1: `TMP` is set by runner-manager",
645            ),
646            (
647                format!("actions_runner_input_url={secret}\n"),
648                "`actions_runner_input_url` is set by runner-manager",
649            ),
650            (
651                format!("A=1\nA={secret}\n"),
652                "line 2 sets `A` a second time",
653            ),
654            (
655                format!("A=1\nB={secret}\0\n"),
656                "line 2: the value of `B` contains a line break or a NUL",
657            ),
658        ] {
659            let error = RunnerEnv::parse(&text).unwrap_err().to_string();
660            assert!(error.contains(expected), "{error:?} lacks {expected:?}");
661            assert!(!error.contains(secret), "an error echoed a value: {error}");
662        }
663    }
664
665    #[test]
666    fn reserved_names_cover_the_temporary_directory_and_every_runner_input() {
667        for name in [
668            "TMPDIR",
669            "TEMP",
670            "TMP",
671            "Tmp",
672            "ACTIONS_RUNNER_INPUT_JITCONFIG",
673            "ACTIONS_RUNNER_INPUT_URL",
674        ] {
675            assert!(is_reserved(name), "{name}");
676        }
677        for name in ["TMPX", "PATH", "ACTIONS_RUNNER_HOOK_JOB_STARTED", "HOME"] {
678            assert!(!is_reserved(name), "{name}");
679        }
680    }
681
682    #[test]
683    fn set_replaces_in_place_and_unset_removes_only_the_entry() {
684        let mut env = RunnerEnv::parse("# keep me\nA=1\nB=2\n").unwrap();
685        assert!(env.set("A", "one").unwrap());
686        assert!(!env.set("C", "3").unwrap());
687        assert!(env.unset("B"));
688        assert!(!env.unset("B"));
689        assert_eq!(env.render(), "# keep me\nA=one\nC=3\n");
690        assert!(matches!(
691            env.set("D", "two\nlines"),
692            Err(RunnerEnvError::LineBreak { .. })
693        ));
694        assert!(matches!(
695            env.set("TEMP", "x"),
696            Err(RunnerEnvError::Reserved { .. })
697        ));
698        assert!(matches!(
699            env.set("NOT-A-NAME", "x"),
700            Err(RunnerEnvError::InvalidName { .. })
701        ));
702    }
703
704    #[cfg(unix)]
705    #[test]
706    fn saving_keeps_the_mode_of_the_file_it_replaces() {
707        use std::os::unix::fs::PermissionsExt as _;
708        let directory = tempfile::tempdir().unwrap();
709        let path = path_in(directory.path());
710        let mut env = RunnerEnv::default();
711        env.set("A", "1").unwrap();
712        env.save(&path).unwrap();
713        let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
714        assert_eq!(mode(&path), 0o600, "a new file is owner-only");
715
716        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
717        env.set("B", "2").unwrap();
718        env.save(&path).unwrap();
719        assert_eq!(mode(&path), 0o644, "the replacement reset the file's mode");
720    }
721
722    #[test]
723    fn a_missing_file_is_empty_and_a_saved_file_loads_back() {
724        let directory = tempfile::tempdir().unwrap();
725        let path = path_in(directory.path());
726        assert!(RunnerEnv::load(&path).unwrap().is_empty());
727
728        let mut env = RunnerEnv::default();
729        env.set("npm_config_cache", "/cache/npm").unwrap();
730        env.save(&path).unwrap();
731        assert_eq!(RunnerEnv::load(&path).unwrap(), env);
732        assert_eq!(
733            fs::read_to_string(&path).unwrap(),
734            "npm_config_cache=/cache/npm\n"
735        );
736    }
737
738    fn defaults(platform: RunnerPlatform, inherited: &Inherited) -> Vec<(&'static str, OsString)> {
739        platform_defaults(platform, Path::new("/rman/a1"), inherited, |dir| {
740            dir != Path::new("/usr/local/bin")
741        })
742    }
743
744    #[test]
745    fn macos_puts_existing_homebrew_directories_ahead_of_the_service_path() {
746        let launchd = Inherited {
747            path: Some("/usr/bin:/bin:/usr/sbin:/sbin".into()),
748            has_locale: false,
749            ..Inherited::default()
750        };
751        assert_eq!(
752            defaults(RunnerPlatform::MacOs, &launchd),
753            [
754                (
755                    "PATH",
756                    OsString::from(
757                        "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin"
758                    )
759                ),
760                ("LANG", OsString::from(MACOS_DEFAULT_LANG)),
761                telemetry_optout(),
762            ]
763        );
764
765        // A directory already listed is not added twice, and a daemon with a
766        // locale keeps it.
767        let login = Inherited {
768            path: Some("/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin".into()),
769            has_locale: true,
770            ..Inherited::default()
771        };
772        assert_eq!(
773            defaults(RunnerPlatform::MacOs, &login),
774            [telemetry_optout()]
775        );
776
777        // A daemon with no PATH keeps the system directories behind Homebrew's.
778        let bare = Inherited {
779            path: None,
780            has_locale: true,
781            ..Inherited::default()
782        };
783        assert_eq!(
784            defaults(RunnerPlatform::MacOs, &bare),
785            [
786                (
787                    "PATH",
788                    OsString::from(format!(
789                        "/opt/homebrew/bin:/opt/homebrew/sbin:{MACOS_SYSTEM_PATH}"
790                    ))
791                ),
792                telemetry_optout(),
793            ]
794        );
795    }
796
797    #[test]
798    fn windows_gives_each_runner_a_profile_inside_its_attempt() {
799        let windows = defaults(RunnerPlatform::Windows, &Inherited::default());
800        let home = Path::new("/rman/a1").join(RUNNER_HOME_DIR);
801        assert_eq!(
802            windows,
803            [
804                ("USERPROFILE", home.clone().into_os_string()),
805                ("HOME", home.clone().into_os_string()),
806                (
807                    "APPDATA",
808                    home.join("AppData").join("Roaming").into_os_string()
809                ),
810                (
811                    "LOCALAPPDATA",
812                    home.join("AppData").join("Local").into_os_string()
813                ),
814                telemetry_optout(),
815            ]
816        );
817        let writable = Inherited {
818            home_writable: true,
819            ..Inherited::default()
820        };
821        assert_eq!(
822            defaults(RunnerPlatform::Linux, &writable),
823            [telemetry_optout()]
824        );
825    }
826
827    #[test]
828    fn linux_gives_a_runner_its_own_home_when_the_services_is_read_only() {
829        assert_eq!(
830            defaults(RunnerPlatform::Linux, &Inherited::default()),
831            [
832                (
833                    "HOME",
834                    Path::new("/rman/a1").join(RUNNER_HOME_DIR).into_os_string()
835                ),
836                telemetry_optout(),
837            ]
838        );
839        assert!(DIRECTORY_VARIABLES.contains(&"HOME"));
840        #[cfg(unix)]
841        {
842            let directory = tempfile::tempdir().unwrap();
843            assert!(writable(directory.path().as_os_str()));
844            assert!(!writable(OsStr::new("/nonexistent/runner-manager-home")));
845        }
846    }
847
848    fn telemetry_optout() -> (&'static str, OsString) {
849        (
850            POWERSHELL_TELEMETRY_OPTOUT,
851            OsString::from(POWERSHELL_TELEMETRY_OPTOUT_VALUE),
852        )
853    }
854
855    /// The daemon's own opt-out counts only when it says something: PowerShell
856    /// reads an empty value as unset, so an empty one is replaced.
857    #[test]
858    fn the_daemon_opt_out_is_read_from_its_environment_and_empty_is_unset() {
859        let with = |value: Option<&str>| {
860            Inherited::from_env(|name| {
861                (name == POWERSHELL_TELEMETRY_OPTOUT)
862                    .then_some(value)
863                    .flatten()
864                    .map(OsString::from)
865            })
866            .has_powershell_telemetry_optout
867        };
868        assert!(with(Some("0")));
869        assert!(with(Some("1")));
870        assert!(!with(Some("")));
871        assert!(!with(None));
872    }
873
874    /// A daemon that already sets the opt-out, to any non-empty value, keeps
875    /// it: nothing is set over it, so the runner inherits the daemon's own.
876    #[test]
877    fn a_daemon_powershell_telemetry_setting_is_left_alone() {
878        let daemon_decided = Inherited {
879            has_powershell_telemetry_optout: true,
880            ..Inherited::default()
881        };
882        for platform in [
883            RunnerPlatform::Windows,
884            RunnerPlatform::MacOs,
885            RunnerPlatform::Linux,
886        ] {
887            assert!(
888                defaults(platform, &daemon_decided)
889                    .iter()
890                    .all(|(name, _)| *name != POWERSHELL_TELEMETRY_OPTOUT),
891                "{platform:?} set the opt-out over the daemon's own value"
892            );
893        }
894    }
895
896    #[test]
897    fn runner_env_replaces_a_default_of_the_same_name_and_adds_the_rest() {
898        let file = RunnerEnv::parse("PATH=/custom/bin\nELECTRON_CACHE=/cache/electron\n").unwrap();
899        let defaults = vec![
900            ("PATH", OsString::from("/opt/homebrew/bin:/usr/bin")),
901            ("LANG", OsString::from(MACOS_DEFAULT_LANG)),
902        ];
903        assert_eq!(
904            runner_environment(defaults, Vec::new(), &file),
905            [
906                (
907                    OsString::from("PATH"),
908                    OsString::from("/opt/homebrew/bin:/usr/bin")
909                ),
910                (OsString::from("LANG"), OsString::from(MACOS_DEFAULT_LANG)),
911                (OsString::from("PATH"), OsString::from("/custom/bin")),
912                (
913                    OsString::from("ELECTRON_CACHE"),
914                    OsString::from("/cache/electron")
915                ),
916            ]
917        );
918    }
919
920    /// Platform defaults < dependency caches < `runner.env`: a cache variable
921    /// replaces a default of the same name, and `runner.env` replaces both.
922    #[test]
923    fn caches_sit_between_the_platform_defaults_and_runner_env() {
924        let file = RunnerEnv::parse("npm_config_cache=/operator/npm\n").unwrap();
925        let defaults = vec![("HOME", OsString::from("/attempt/home"))];
926        let caches = vec![
927            ("npm_config_cache", OsString::from("/cache/o/r/npm")),
928            ("HOME", OsString::from("/cache-would-never-set-this")),
929        ];
930        let applied = runner_environment(defaults, caches, &file);
931        let last = |name: &str| {
932            applied
933                .iter()
934                .rev()
935                .find(|(n, _)| n == name)
936                .map(|(_, v)| v.clone())
937        };
938        assert_eq!(last("npm_config_cache"), Some("/operator/npm".into()));
939        assert_eq!(last("HOME"), Some("/cache-would-never-set-this".into()));
940        let position = |value: &str| applied.iter().position(|(_, v)| v == value).unwrap();
941        assert!(position("/attempt/home") < position("/cache/o/r/npm"));
942        assert!(position("/cache/o/r/npm") < position("/operator/npm"));
943    }
944}