use std::ffi::{OsStr, OsString};
use std::fs;
use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
pub const RUNNER_ENV_FILE: &str = "runner.env";
pub const RESERVED_NAMES: [&str; 3] = ["TMPDIR", "TEMP", "TMP"];
pub const RESERVED_PREFIX: &str = "ACTIONS_RUNNER_INPUT_";
pub const MACOS_TOOL_DIRECTORIES: [&str; 3] =
["/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin"];
pub const MACOS_SYSTEM_PATH: &str = "/usr/bin:/bin:/usr/sbin:/sbin";
pub const MACOS_DEFAULT_LANG: &str = "en_US.UTF-8";
#[must_use]
pub fn path_in(config_dir: &Path) -> PathBuf {
config_dir.join(RUNNER_ENV_FILE)
}
#[derive(Debug, thiserror::Error)]
pub enum RunnerEnvError {
#[error("{}is not KEY=VALUE", at(*.line))]
NotAnAssignment { line: Option<usize> },
#[error(
"{}the name before `=` is not a valid environment variable name; use letters, \
digits and `_`, not starting with a digit",
at(*.line)
)]
InvalidName { line: Option<usize> },
#[error(
"{}`{name}` is set by runner-manager for every runner and cannot be changed",
at(*.line)
)]
Reserved { line: Option<usize>, name: String },
#[error("line {line} sets `{name}` a second time")]
Duplicate { line: usize, name: String },
#[error(
"{}the value of `{name}` contains a line break or a NUL character, which runner.env \
cannot hold",
at(*.line)
)]
LineBreak { line: Option<usize>, name: String },
#[error("cannot {action} {}: {source}", path.display())]
Io {
action: &'static str,
path: PathBuf,
#[source]
source: io::Error,
},
}
fn at(line: Option<usize>) -> String {
line.map_or_else(String::new, |line| format!("line {line}: "))
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Line {
Text(String),
Entry { name: String, value: String },
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RunnerEnv {
lines: Vec<Line>,
}
impl RunnerEnv {
pub fn parse(text: &str) -> Result<Self, RunnerEnvError> {
let mut env = Self::default();
for (index, raw) in text.lines().enumerate() {
let line = index + 1;
let raw = raw.strip_suffix('\r').unwrap_or(raw);
let trimmed = raw.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
env.lines.push(Line::Text(raw.to_owned()));
continue;
}
let (name, value) = trimmed
.split_once('=')
.ok_or(RunnerEnvError::NotAnAssignment { line: Some(line) })?;
let name = name.trim_end();
validate_name(name, Some(line))?;
if value.contains('\0') {
return Err(RunnerEnvError::LineBreak {
line: Some(line),
name: name.to_owned(),
});
}
if env.get(name).is_some() {
return Err(RunnerEnvError::Duplicate {
line,
name: name.to_owned(),
});
}
env.lines.push(Line::Entry {
name: name.to_owned(),
value: value.to_owned(),
});
}
Ok(env)
}
pub fn load(path: &Path) -> Result<Self, RunnerEnvError> {
match fs::read_to_string(path) {
Ok(text) => Self::parse(&text),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
Err(source) => Err(RunnerEnvError::Io {
action: "read",
path: path.to_path_buf(),
source,
}),
}
}
pub fn save(&self, path: &Path) -> Result<(), RunnerEnvError> {
let failed = |action, source| RunnerEnvError::Io {
action,
path: path.to_path_buf(),
source,
};
let parent = path.parent().unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent).map_err(|source| failed("create the directory of", source))?;
let mut temporary =
tempfile::NamedTempFile::new_in(parent).map_err(|source| failed("write", source))?;
temporary
.write_all(self.render().as_bytes())
.and_then(|()| temporary.as_file().sync_all())
.map_err(|source| failed("write", source))?;
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
let existing = fs::metadata(path).ok();
if let Some(owner) = existing.clone().or_else(|| fs::metadata(parent).ok()) {
let _ = std::os::unix::fs::chown(
temporary.path(),
Some(owner.uid()),
Some(owner.gid()),
);
}
if let Some(existing) = existing {
let mode = existing.permissions().mode() & 0o777;
temporary
.as_file()
.set_permissions(fs::Permissions::from_mode(mode))
.map_err(|source| failed("write", source))?;
}
}
temporary
.persist(path)
.map(|_| ())
.map_err(|error| failed("replace", error.error))
}
fn render(&self) -> String {
let mut text = String::new();
for line in &self.lines {
match line {
Line::Text(raw) => text.push_str(raw),
Line::Entry { name, value } => {
text.push_str(name);
text.push('=');
text.push_str(value);
}
}
text.push('\n');
}
text
}
pub fn set(&mut self, name: &str, value: &str) -> Result<bool, RunnerEnvError> {
validate_name(name, None)?;
if value.contains(['\n', '\r', '\0']) {
return Err(RunnerEnvError::LineBreak {
line: None,
name: name.to_owned(),
});
}
for line in &mut self.lines {
if let Line::Entry {
name: existing,
value: current,
} = line
&& same_name(existing, name)
{
*current = value.to_owned();
return Ok(true);
}
}
self.lines.push(Line::Entry {
name: name.to_owned(),
value: value.to_owned(),
});
Ok(false)
}
pub fn unset(&mut self, name: &str) -> bool {
let before = self.lines.len();
self.lines.retain(
|line| !matches!(line, Line::Entry { name: existing, .. } if same_name(existing, name)),
);
self.lines.len() != before
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&str> {
self.entries()
.find(|(existing, _)| same_name(existing, name))
.map(|(_, value)| value)
}
pub fn entries(&self) -> impl Iterator<Item = (&str, &str)> {
self.lines.iter().filter_map(|line| match line {
Line::Entry { name, value } => Some((name.as_str(), value.as_str())),
Line::Text(_) => None,
})
}
#[must_use]
pub fn len(&self) -> usize {
self.entries().count()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
pub fn split_assignment(raw: &str) -> Result<(&str, &str), RunnerEnvError> {
raw.split_once('=')
.ok_or(RunnerEnvError::NotAnAssignment { line: None })
}
fn validate_name(name: &str, line: Option<usize>) -> Result<(), RunnerEnvError> {
let mut chars = name.chars();
let valid = chars
.next()
.is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
if !valid {
return Err(RunnerEnvError::InvalidName { line });
}
if is_reserved(name) {
return Err(RunnerEnvError::Reserved {
line,
name: name.to_owned(),
});
}
Ok(())
}
fn is_reserved(name: &str) -> bool {
let upper = name.to_ascii_uppercase();
upper.starts_with(RESERVED_PREFIX) || RESERVED_NAMES.contains(&upper.as_str())
}
pub(crate) fn same_name(a: &str, b: &str) -> bool {
if cfg!(windows) {
a.eq_ignore_ascii_case(b)
} else {
a == b
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunnerPlatform {
Windows,
MacOs,
Linux,
}
impl RunnerPlatform {
#[must_use]
pub const fn current() -> Self {
if cfg!(windows) {
Self::Windows
} else if cfg!(target_os = "macos") {
Self::MacOs
} else {
Self::Linux
}
}
}
pub const POWERSHELL_TELEMETRY_OPTOUT: &str = "POWERSHELL_TELEMETRY_OPTOUT";
pub const POWERSHELL_TELEMETRY_OPTOUT_VALUE: &str = "1";
#[derive(Debug, Clone, Default)]
pub struct Inherited {
pub path: Option<OsString>,
pub has_locale: bool,
pub has_powershell_telemetry_optout: bool,
}
impl Inherited {
#[must_use]
pub fn current() -> Self {
Self::from_env(|name| std::env::var_os(name))
}
fn from_env(var: impl Fn(&str) -> Option<OsString>) -> Self {
Self {
path: var("PATH"),
has_locale: var("LANG").is_some() || var("LC_ALL").is_some(),
has_powershell_telemetry_optout: var(POWERSHELL_TELEMETRY_OPTOUT)
.is_some_and(|value| !value.is_empty()),
}
}
}
pub const RUNNER_HOME_DIR: &str = "home";
pub const WINDOWS_PROFILE_VARIABLES: [&str; 4] = ["USERPROFILE", "HOME", "APPDATA", "LOCALAPPDATA"];
pub const DIRECTORY_VARIABLES: [&str; 2] = ["APPDATA", "LOCALAPPDATA"];
#[must_use]
pub fn platform_defaults(
platform: RunnerPlatform,
runtime: &Path,
inherited: &Inherited,
exists: impl Fn(&Path) -> bool,
) -> Vec<(&'static str, OsString)> {
let mut defaults = match platform {
RunnerPlatform::Linux => Vec::new(),
RunnerPlatform::Windows => {
let home = runtime.join(RUNNER_HOME_DIR);
let app_data = home.join("AppData");
let [user_profile, home_name, roaming, local] = WINDOWS_PROFILE_VARIABLES;
vec![
(user_profile, home.clone().into_os_string()),
(home_name, home.into_os_string()),
(roaming, app_data.join("Roaming").into_os_string()),
(local, app_data.join("Local").into_os_string()),
]
}
RunnerPlatform::MacOs => {
let mut defaults = Vec::new();
let listed: Vec<&OsStr> = inherited
.path
.as_deref()
.map(|path| split_colon_path(path))
.unwrap_or_default();
let missing: Vec<&str> = MACOS_TOOL_DIRECTORIES
.into_iter()
.filter(|dir| exists(Path::new(dir)) && !listed.contains(&OsStr::new(dir)))
.collect();
if !missing.is_empty() {
let mut path = OsString::from(missing.join(":"));
path.push(":");
match inherited.path.as_deref().filter(|p| !p.is_empty()) {
Some(inherited) => path.push(inherited),
None => path.push(MACOS_SYSTEM_PATH),
}
defaults.push(("PATH", path));
}
if !inherited.has_locale {
defaults.push(("LANG", OsString::from(MACOS_DEFAULT_LANG)));
}
defaults
}
};
if !inherited.has_powershell_telemetry_optout {
defaults.push((
POWERSHELL_TELEMETRY_OPTOUT,
OsString::from(POWERSHELL_TELEMETRY_OPTOUT_VALUE),
));
}
defaults
}
fn split_colon_path(path: &OsStr) -> Vec<&OsStr> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
path.as_bytes()
.split(|byte| *byte == b':')
.map(OsStr::from_bytes)
.collect()
}
#[cfg(not(unix))]
{
path.to_str()
.map(|text| text.split(':').map(OsStr::new).collect())
.unwrap_or_default()
}
}
#[must_use]
pub fn runner_environment(
defaults: Vec<(&'static str, OsString)>,
caches: Vec<(&'static str, OsString)>,
file: &RunnerEnv,
) -> Vec<(OsString, OsString)> {
defaults
.into_iter()
.chain(caches)
.map(|(name, value)| (OsString::from(name), value))
.chain(
file.entries()
.map(|(name, value)| (OsString::from(name), OsString::from(value))),
)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_file_parses_keeps_comments_and_round_trips() {
let text = "# caches off the boot volume\n\nELECTRON_CACHE=/Volumes/NVME/cache/electron\n\
npm_config_cache=/Volumes/NVME/cache/npm\nEMPTY=\nURL=https://x/?a=b\n";
let env = RunnerEnv::parse(text).unwrap();
assert_eq!(
env.entries().collect::<Vec<_>>(),
[
("ELECTRON_CACHE", "/Volumes/NVME/cache/electron"),
("npm_config_cache", "/Volumes/NVME/cache/npm"),
("EMPTY", ""),
("URL", "https://x/?a=b"),
]
);
assert_eq!(env.render(), text);
assert_eq!(
RunnerEnv::parse("A=1\r\nB=2\r\n").unwrap().get("B"),
Some("2")
);
}
#[test]
fn a_bad_line_is_named_by_number_and_never_echoed() {
let secret = "ghp_notarealtokenbutshapedlikeone";
for (text, expected) in [
(format!("A=1\n{secret}\n"), "line 2: is not KEY=VALUE"),
(format!("A=1\n1BAD={secret}\n"), "line 2: the name"),
(
format!("TMP={secret}\n"),
"line 1: `TMP` is set by runner-manager",
),
(
format!("actions_runner_input_url={secret}\n"),
"`actions_runner_input_url` is set by runner-manager",
),
(
format!("A=1\nA={secret}\n"),
"line 2 sets `A` a second time",
),
(
format!("A=1\nB={secret}\0\n"),
"line 2: the value of `B` contains a line break or a NUL",
),
] {
let error = RunnerEnv::parse(&text).unwrap_err().to_string();
assert!(error.contains(expected), "{error:?} lacks {expected:?}");
assert!(!error.contains(secret), "an error echoed a value: {error}");
}
}
#[test]
fn reserved_names_cover_the_temporary_directory_and_every_runner_input() {
for name in [
"TMPDIR",
"TEMP",
"TMP",
"Tmp",
"ACTIONS_RUNNER_INPUT_JITCONFIG",
"ACTIONS_RUNNER_INPUT_URL",
] {
assert!(is_reserved(name), "{name}");
}
for name in ["TMPX", "PATH", "ACTIONS_RUNNER_HOOK_JOB_STARTED", "HOME"] {
assert!(!is_reserved(name), "{name}");
}
}
#[test]
fn set_replaces_in_place_and_unset_removes_only_the_entry() {
let mut env = RunnerEnv::parse("# keep me\nA=1\nB=2\n").unwrap();
assert!(env.set("A", "one").unwrap());
assert!(!env.set("C", "3").unwrap());
assert!(env.unset("B"));
assert!(!env.unset("B"));
assert_eq!(env.render(), "# keep me\nA=one\nC=3\n");
assert!(matches!(
env.set("D", "two\nlines"),
Err(RunnerEnvError::LineBreak { .. })
));
assert!(matches!(
env.set("TEMP", "x"),
Err(RunnerEnvError::Reserved { .. })
));
assert!(matches!(
env.set("NOT-A-NAME", "x"),
Err(RunnerEnvError::InvalidName { .. })
));
}
#[cfg(unix)]
#[test]
fn saving_keeps_the_mode_of_the_file_it_replaces() {
use std::os::unix::fs::PermissionsExt as _;
let directory = tempfile::tempdir().unwrap();
let path = path_in(directory.path());
let mut env = RunnerEnv::default();
env.set("A", "1").unwrap();
env.save(&path).unwrap();
let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&path), 0o600, "a new file is owner-only");
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
env.set("B", "2").unwrap();
env.save(&path).unwrap();
assert_eq!(mode(&path), 0o644, "the replacement reset the file's mode");
}
#[test]
fn a_missing_file_is_empty_and_a_saved_file_loads_back() {
let directory = tempfile::tempdir().unwrap();
let path = path_in(directory.path());
assert!(RunnerEnv::load(&path).unwrap().is_empty());
let mut env = RunnerEnv::default();
env.set("npm_config_cache", "/cache/npm").unwrap();
env.save(&path).unwrap();
assert_eq!(RunnerEnv::load(&path).unwrap(), env);
assert_eq!(
fs::read_to_string(&path).unwrap(),
"npm_config_cache=/cache/npm\n"
);
}
fn defaults(platform: RunnerPlatform, inherited: &Inherited) -> Vec<(&'static str, OsString)> {
platform_defaults(platform, Path::new("/rman/a1"), inherited, |dir| {
dir != Path::new("/usr/local/bin")
})
}
#[test]
fn macos_puts_existing_homebrew_directories_ahead_of_the_service_path() {
let launchd = Inherited {
path: Some("/usr/bin:/bin:/usr/sbin:/sbin".into()),
has_locale: false,
..Inherited::default()
};
assert_eq!(
defaults(RunnerPlatform::MacOs, &launchd),
[
(
"PATH",
OsString::from(
"/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin"
)
),
("LANG", OsString::from(MACOS_DEFAULT_LANG)),
telemetry_optout(),
]
);
let login = Inherited {
path: Some("/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin".into()),
has_locale: true,
..Inherited::default()
};
assert_eq!(
defaults(RunnerPlatform::MacOs, &login),
[telemetry_optout()]
);
let bare = Inherited {
path: None,
has_locale: true,
..Inherited::default()
};
assert_eq!(
defaults(RunnerPlatform::MacOs, &bare),
[
(
"PATH",
OsString::from(format!(
"/opt/homebrew/bin:/opt/homebrew/sbin:{MACOS_SYSTEM_PATH}"
))
),
telemetry_optout(),
]
);
}
#[test]
fn windows_gives_each_runner_a_profile_inside_its_attempt() {
let windows = defaults(RunnerPlatform::Windows, &Inherited::default());
let home = Path::new("/rman/a1").join(RUNNER_HOME_DIR);
assert_eq!(
windows,
[
("USERPROFILE", home.clone().into_os_string()),
("HOME", home.clone().into_os_string()),
(
"APPDATA",
home.join("AppData").join("Roaming").into_os_string()
),
(
"LOCALAPPDATA",
home.join("AppData").join("Local").into_os_string()
),
telemetry_optout(),
]
);
assert_eq!(
defaults(RunnerPlatform::Linux, &Inherited::default()),
[telemetry_optout()]
);
}
fn telemetry_optout() -> (&'static str, OsString) {
(
POWERSHELL_TELEMETRY_OPTOUT,
OsString::from(POWERSHELL_TELEMETRY_OPTOUT_VALUE),
)
}
#[test]
fn the_daemon_opt_out_is_read_from_its_environment_and_empty_is_unset() {
let with = |value: Option<&str>| {
Inherited::from_env(|name| {
(name == POWERSHELL_TELEMETRY_OPTOUT)
.then_some(value)
.flatten()
.map(OsString::from)
})
.has_powershell_telemetry_optout
};
assert!(with(Some("0")));
assert!(with(Some("1")));
assert!(!with(Some("")));
assert!(!with(None));
}
#[test]
fn a_daemon_powershell_telemetry_setting_is_left_alone() {
let daemon_decided = Inherited {
has_powershell_telemetry_optout: true,
..Inherited::default()
};
for platform in [
RunnerPlatform::Windows,
RunnerPlatform::MacOs,
RunnerPlatform::Linux,
] {
assert!(
defaults(platform, &daemon_decided)
.iter()
.all(|(name, _)| *name != POWERSHELL_TELEMETRY_OPTOUT),
"{platform:?} set the opt-out over the daemon's own value"
);
}
}
#[test]
fn runner_env_replaces_a_default_of_the_same_name_and_adds_the_rest() {
let file = RunnerEnv::parse("PATH=/custom/bin\nELECTRON_CACHE=/cache/electron\n").unwrap();
let defaults = vec![
("PATH", OsString::from("/opt/homebrew/bin:/usr/bin")),
("LANG", OsString::from(MACOS_DEFAULT_LANG)),
];
assert_eq!(
runner_environment(defaults, Vec::new(), &file),
[
(
OsString::from("PATH"),
OsString::from("/opt/homebrew/bin:/usr/bin")
),
(OsString::from("LANG"), OsString::from(MACOS_DEFAULT_LANG)),
(OsString::from("PATH"), OsString::from("/custom/bin")),
(
OsString::from("ELECTRON_CACHE"),
OsString::from("/cache/electron")
),
]
);
}
#[test]
fn caches_sit_between_the_platform_defaults_and_runner_env() {
let file = RunnerEnv::parse("npm_config_cache=/operator/npm\n").unwrap();
let defaults = vec![("HOME", OsString::from("/attempt/home"))];
let caches = vec![
("npm_config_cache", OsString::from("/cache/o/r/npm")),
("HOME", OsString::from("/cache-would-never-set-this")),
];
let applied = runner_environment(defaults, caches, &file);
let last = |name: &str| {
applied
.iter()
.rev()
.find(|(n, _)| n == name)
.map(|(_, v)| v.clone())
};
assert_eq!(last("npm_config_cache"), Some("/operator/npm".into()));
assert_eq!(last("HOME"), Some("/cache-would-never-set-this".into()));
let position = |value: &str| applied.iter().position(|(_, v)| v == value).unwrap();
assert!(position("/attempt/home") < position("/cache/o/r/npm"));
assert!(position("/cache/o/r/npm") < position("/operator/npm"));
}
}