1use std::ffi::{OsStr, OsString};
36use std::fs;
37use std::io::{self, Write as _};
38use std::path::{Path, PathBuf};
39
40pub const RUNNER_ENV_FILE: &str = "runner.env";
42
43pub const RESERVED_NAMES: [&str; 3] = ["TMPDIR", "TEMP", "TMP"];
46
47pub const RESERVED_PREFIX: &str = "ACTIONS_RUNNER_INPUT_";
51
52pub const MACOS_TOOL_DIRECTORIES: [&str; 3] =
55 ["/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin"];
56
57pub const MACOS_SYSTEM_PATH: &str = "/usr/bin:/bin:/usr/sbin:/sbin";
60
61pub const MACOS_DEFAULT_LANG: &str = "en_US.UTF-8";
65
66#[must_use]
68pub fn path_in(config_dir: &Path) -> PathBuf {
69 config_dir.join(RUNNER_ENV_FILE)
70}
71
72#[derive(Debug, thiserror::Error)]
75pub enum RunnerEnvError {
76 #[error("{}is not KEY=VALUE", at(*.line))]
77 NotAnAssignment { line: Option<usize> },
78 #[error(
79 "{}the name before `=` is not a valid environment variable name; use letters, \
80 digits and `_`, not starting with a digit",
81 at(*.line)
82 )]
83 InvalidName { line: Option<usize> },
84 #[error(
85 "{}`{name}` is set by runner-manager for every runner and cannot be changed",
86 at(*.line)
87 )]
88 Reserved { line: Option<usize>, name: String },
89 #[error("line {line} sets `{name}` a second time")]
90 Duplicate { line: usize, name: String },
91 #[error(
92 "{}the value of `{name}` contains a line break or a NUL character, which runner.env \
93 cannot hold",
94 at(*.line)
95 )]
96 LineBreak { line: Option<usize>, name: String },
97 #[error("cannot {action} {}: {source}", path.display())]
98 Io {
99 action: &'static str,
100 path: PathBuf,
101 #[source]
102 source: io::Error,
103 },
104}
105
106fn at(line: Option<usize>) -> String {
107 line.map_or_else(String::new, |line| format!("line {line}: "))
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
113enum Line {
114 Text(String),
115 Entry { name: String, value: String },
116}
117
118#[derive(Debug, Clone, Default, PartialEq, Eq)]
120pub struct RunnerEnv {
121 lines: Vec<Line>,
122}
123
124impl RunnerEnv {
125 pub fn parse(text: &str) -> Result<Self, RunnerEnvError> {
133 let mut env = Self::default();
134 for (index, raw) in text.lines().enumerate() {
135 let line = index + 1;
136 let raw = raw.strip_suffix('\r').unwrap_or(raw);
137 let trimmed = raw.trim_start();
138 if trimmed.is_empty() || trimmed.starts_with('#') {
139 env.lines.push(Line::Text(raw.to_owned()));
140 continue;
141 }
142 let (name, value) = trimmed
143 .split_once('=')
144 .ok_or(RunnerEnvError::NotAnAssignment { line: Some(line) })?;
145 let name = name.trim_end();
146 validate_name(name, Some(line))?;
147 if value.contains('\0') {
150 return Err(RunnerEnvError::LineBreak {
151 line: Some(line),
152 name: name.to_owned(),
153 });
154 }
155 if env.get(name).is_some() {
156 return Err(RunnerEnvError::Duplicate {
157 line,
158 name: name.to_owned(),
159 });
160 }
161 env.lines.push(Line::Entry {
162 name: name.to_owned(),
163 value: value.to_owned(),
164 });
165 }
166 Ok(env)
167 }
168
169 pub fn load(path: &Path) -> Result<Self, RunnerEnvError> {
175 match fs::read_to_string(path) {
176 Ok(text) => Self::parse(&text),
177 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
178 Err(source) => Err(RunnerEnvError::Io {
179 action: "read",
180 path: path.to_path_buf(),
181 source,
182 }),
183 }
184 }
185
186 pub fn save(&self, path: &Path) -> Result<(), RunnerEnvError> {
198 let failed = |action, source| RunnerEnvError::Io {
199 action,
200 path: path.to_path_buf(),
201 source,
202 };
203 let parent = path.parent().unwrap_or_else(|| Path::new("."));
204 fs::create_dir_all(parent).map_err(|source| failed("create the directory of", source))?;
205 let mut temporary =
206 tempfile::NamedTempFile::new_in(parent).map_err(|source| failed("write", source))?;
207 temporary
208 .write_all(self.render().as_bytes())
209 .and_then(|()| temporary.as_file().sync_all())
210 .map_err(|source| failed("write", source))?;
211 #[cfg(unix)]
212 {
213 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
214 let existing = fs::metadata(path).ok();
215 if let Some(owner) = existing.clone().or_else(|| fs::metadata(parent).ok()) {
216 let _ = std::os::unix::fs::chown(
219 temporary.path(),
220 Some(owner.uid()),
221 Some(owner.gid()),
222 );
223 }
224 if let Some(existing) = existing {
225 let mode = existing.permissions().mode() & 0o777;
226 temporary
227 .as_file()
228 .set_permissions(fs::Permissions::from_mode(mode))
229 .map_err(|source| failed("write", source))?;
230 }
231 }
232 temporary
233 .persist(path)
234 .map(|_| ())
235 .map_err(|error| failed("replace", error.error))
236 }
237
238 fn render(&self) -> String {
240 let mut text = String::new();
241 for line in &self.lines {
242 match line {
243 Line::Text(raw) => text.push_str(raw),
244 Line::Entry { name, value } => {
245 text.push_str(name);
246 text.push('=');
247 text.push_str(value);
248 }
249 }
250 text.push('\n');
251 }
252 text
253 }
254
255 pub fn set(&mut self, name: &str, value: &str) -> Result<bool, RunnerEnvError> {
261 validate_name(name, None)?;
262 if value.contains(['\n', '\r', '\0']) {
263 return Err(RunnerEnvError::LineBreak {
264 line: None,
265 name: name.to_owned(),
266 });
267 }
268 for line in &mut self.lines {
269 if let Line::Entry {
270 name: existing,
271 value: current,
272 } = line
273 && same_name(existing, name)
274 {
275 *current = value.to_owned();
276 return Ok(true);
277 }
278 }
279 self.lines.push(Line::Entry {
280 name: name.to_owned(),
281 value: value.to_owned(),
282 });
283 Ok(false)
284 }
285
286 pub fn unset(&mut self, name: &str) -> bool {
288 let before = self.lines.len();
289 self.lines.retain(
290 |line| !matches!(line, Line::Entry { name: existing, .. } if same_name(existing, name)),
291 );
292 self.lines.len() != before
293 }
294
295 #[must_use]
297 pub fn get(&self, name: &str) -> Option<&str> {
298 self.entries()
299 .find(|(existing, _)| same_name(existing, name))
300 .map(|(_, value)| value)
301 }
302
303 pub fn entries(&self) -> impl Iterator<Item = (&str, &str)> {
305 self.lines.iter().filter_map(|line| match line {
306 Line::Entry { name, value } => Some((name.as_str(), value.as_str())),
307 Line::Text(_) => None,
308 })
309 }
310
311 #[must_use]
313 pub fn len(&self) -> usize {
314 self.entries().count()
315 }
316
317 #[must_use]
319 pub fn is_empty(&self) -> bool {
320 self.len() == 0
321 }
322}
323
324pub fn split_assignment(raw: &str) -> Result<(&str, &str), RunnerEnvError> {
329 raw.split_once('=')
330 .ok_or(RunnerEnvError::NotAnAssignment { line: None })
331}
332
333fn validate_name(name: &str, line: Option<usize>) -> Result<(), RunnerEnvError> {
336 let mut chars = name.chars();
337 let valid = chars
338 .next()
339 .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
340 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
341 if !valid {
342 return Err(RunnerEnvError::InvalidName { line });
343 }
344 if is_reserved(name) {
345 return Err(RunnerEnvError::Reserved {
346 line,
347 name: name.to_owned(),
348 });
349 }
350 Ok(())
351}
352
353fn is_reserved(name: &str) -> bool {
356 let upper = name.to_ascii_uppercase();
357 upper.starts_with(RESERVED_PREFIX) || RESERVED_NAMES.contains(&upper.as_str())
358}
359
360pub(crate) fn same_name(a: &str, b: &str) -> bool {
362 if cfg!(windows) {
363 a.eq_ignore_ascii_case(b)
364 } else {
365 a == b
366 }
367}
368
369#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376pub enum RunnerPlatform {
377 Windows,
378 MacOs,
379 Linux,
380}
381
382impl RunnerPlatform {
383 #[must_use]
385 pub const fn current() -> Self {
386 if cfg!(windows) {
387 Self::Windows
388 } else if cfg!(target_os = "macos") {
389 Self::MacOs
390 } else {
391 Self::Linux
392 }
393 }
394}
395
396#[derive(Debug, Clone, Default)]
398pub struct Inherited {
399 pub path: Option<OsString>,
400 pub has_locale: bool,
402}
403
404impl Inherited {
405 #[must_use]
407 pub fn current() -> Self {
408 Self {
409 path: std::env::var_os("PATH"),
410 has_locale: std::env::var_os("LANG").is_some() || std::env::var_os("LC_ALL").is_some(),
411 }
412 }
413}
414
415pub const RUNNER_HOME_DIR: &str = "home";
417
418pub const WINDOWS_PROFILE_VARIABLES: [&str; 4] = ["USERPROFILE", "HOME", "APPDATA", "LOCALAPPDATA"];
421
422pub const DIRECTORY_VARIABLES: [&str; 2] = ["APPDATA", "LOCALAPPDATA"];
425
426#[must_use]
441pub fn platform_defaults(
442 platform: RunnerPlatform,
443 runtime: &Path,
444 inherited: &Inherited,
445 exists: impl Fn(&Path) -> bool,
446) -> Vec<(&'static str, OsString)> {
447 match platform {
448 RunnerPlatform::Linux => Vec::new(),
449 RunnerPlatform::Windows => {
450 let home = runtime.join(RUNNER_HOME_DIR);
451 let app_data = home.join("AppData");
452 let [user_profile, home_name, roaming, local] = WINDOWS_PROFILE_VARIABLES;
453 vec![
454 (user_profile, home.clone().into_os_string()),
455 (home_name, home.into_os_string()),
456 (roaming, app_data.join("Roaming").into_os_string()),
457 (local, app_data.join("Local").into_os_string()),
458 ]
459 }
460 RunnerPlatform::MacOs => {
461 let mut defaults = Vec::new();
462 let listed: Vec<&OsStr> = inherited
463 .path
464 .as_deref()
465 .map(|path| split_colon_path(path))
466 .unwrap_or_default();
467 let missing: Vec<&str> = MACOS_TOOL_DIRECTORIES
468 .into_iter()
469 .filter(|dir| exists(Path::new(dir)) && !listed.contains(&OsStr::new(dir)))
470 .collect();
471 if !missing.is_empty() {
472 let mut path = OsString::from(missing.join(":"));
473 path.push(":");
474 match inherited.path.as_deref().filter(|p| !p.is_empty()) {
478 Some(inherited) => path.push(inherited),
479 None => path.push(MACOS_SYSTEM_PATH),
480 }
481 defaults.push(("PATH", path));
482 }
483 if !inherited.has_locale {
484 defaults.push(("LANG", OsString::from(MACOS_DEFAULT_LANG)));
485 }
486 defaults
487 }
488 }
489}
490
491fn split_colon_path(path: &OsStr) -> Vec<&OsStr> {
494 #[cfg(unix)]
495 {
496 use std::os::unix::ffi::OsStrExt;
497 path.as_bytes()
498 .split(|byte| *byte == b':')
499 .map(OsStr::from_bytes)
500 .collect()
501 }
502 #[cfg(not(unix))]
503 {
504 path.to_str()
505 .map(|text| text.split(':').map(OsStr::new).collect())
506 .unwrap_or_default()
507 }
508}
509
510#[must_use]
516pub fn runner_environment(
517 defaults: Vec<(&'static str, OsString)>,
518 caches: Vec<(&'static str, OsString)>,
519 file: &RunnerEnv,
520) -> Vec<(OsString, OsString)> {
521 defaults
522 .into_iter()
523 .chain(caches)
524 .map(|(name, value)| (OsString::from(name), value))
525 .chain(
526 file.entries()
527 .map(|(name, value)| (OsString::from(name), OsString::from(value))),
528 )
529 .collect()
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 #[test]
537 fn a_file_parses_keeps_comments_and_round_trips() {
538 let text = "# caches off the boot volume\n\nELECTRON_CACHE=/Volumes/NVME/cache/electron\n\
539 npm_config_cache=/Volumes/NVME/cache/npm\nEMPTY=\nURL=https://x/?a=b\n";
540 let env = RunnerEnv::parse(text).unwrap();
541 assert_eq!(
542 env.entries().collect::<Vec<_>>(),
543 [
544 ("ELECTRON_CACHE", "/Volumes/NVME/cache/electron"),
545 ("npm_config_cache", "/Volumes/NVME/cache/npm"),
546 ("EMPTY", ""),
547 ("URL", "https://x/?a=b"),
548 ]
549 );
550 assert_eq!(env.render(), text);
551 assert_eq!(
552 RunnerEnv::parse("A=1\r\nB=2\r\n").unwrap().get("B"),
553 Some("2")
554 );
555 }
556
557 #[test]
558 fn a_bad_line_is_named_by_number_and_never_echoed() {
559 let secret = "ghp_notarealtokenbutshapedlikeone";
560 for (text, expected) in [
561 (format!("A=1\n{secret}\n"), "line 2: is not KEY=VALUE"),
562 (format!("A=1\n1BAD={secret}\n"), "line 2: the name"),
563 (
564 format!("TMP={secret}\n"),
565 "line 1: `TMP` is set by runner-manager",
566 ),
567 (
568 format!("actions_runner_input_url={secret}\n"),
569 "`actions_runner_input_url` is set by runner-manager",
570 ),
571 (
572 format!("A=1\nA={secret}\n"),
573 "line 2 sets `A` a second time",
574 ),
575 (
576 format!("A=1\nB={secret}\0\n"),
577 "line 2: the value of `B` contains a line break or a NUL",
578 ),
579 ] {
580 let error = RunnerEnv::parse(&text).unwrap_err().to_string();
581 assert!(error.contains(expected), "{error:?} lacks {expected:?}");
582 assert!(!error.contains(secret), "an error echoed a value: {error}");
583 }
584 }
585
586 #[test]
587 fn reserved_names_cover_the_temporary_directory_and_every_runner_input() {
588 for name in [
589 "TMPDIR",
590 "TEMP",
591 "TMP",
592 "Tmp",
593 "ACTIONS_RUNNER_INPUT_JITCONFIG",
594 "ACTIONS_RUNNER_INPUT_URL",
595 ] {
596 assert!(is_reserved(name), "{name}");
597 }
598 for name in ["TMPX", "PATH", "ACTIONS_RUNNER_HOOK_JOB_STARTED", "HOME"] {
599 assert!(!is_reserved(name), "{name}");
600 }
601 }
602
603 #[test]
604 fn set_replaces_in_place_and_unset_removes_only_the_entry() {
605 let mut env = RunnerEnv::parse("# keep me\nA=1\nB=2\n").unwrap();
606 assert!(env.set("A", "one").unwrap());
607 assert!(!env.set("C", "3").unwrap());
608 assert!(env.unset("B"));
609 assert!(!env.unset("B"));
610 assert_eq!(env.render(), "# keep me\nA=one\nC=3\n");
611 assert!(matches!(
612 env.set("D", "two\nlines"),
613 Err(RunnerEnvError::LineBreak { .. })
614 ));
615 assert!(matches!(
616 env.set("TEMP", "x"),
617 Err(RunnerEnvError::Reserved { .. })
618 ));
619 assert!(matches!(
620 env.set("NOT-A-NAME", "x"),
621 Err(RunnerEnvError::InvalidName { .. })
622 ));
623 }
624
625 #[cfg(unix)]
626 #[test]
627 fn saving_keeps_the_mode_of_the_file_it_replaces() {
628 use std::os::unix::fs::PermissionsExt as _;
629 let directory = tempfile::tempdir().unwrap();
630 let path = path_in(directory.path());
631 let mut env = RunnerEnv::default();
632 env.set("A", "1").unwrap();
633 env.save(&path).unwrap();
634 let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
635 assert_eq!(mode(&path), 0o600, "a new file is owner-only");
636
637 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
638 env.set("B", "2").unwrap();
639 env.save(&path).unwrap();
640 assert_eq!(mode(&path), 0o644, "the replacement reset the file's mode");
641 }
642
643 #[test]
644 fn a_missing_file_is_empty_and_a_saved_file_loads_back() {
645 let directory = tempfile::tempdir().unwrap();
646 let path = path_in(directory.path());
647 assert!(RunnerEnv::load(&path).unwrap().is_empty());
648
649 let mut env = RunnerEnv::default();
650 env.set("npm_config_cache", "/cache/npm").unwrap();
651 env.save(&path).unwrap();
652 assert_eq!(RunnerEnv::load(&path).unwrap(), env);
653 assert_eq!(
654 fs::read_to_string(&path).unwrap(),
655 "npm_config_cache=/cache/npm\n"
656 );
657 }
658
659 fn defaults(platform: RunnerPlatform, inherited: &Inherited) -> Vec<(&'static str, OsString)> {
660 platform_defaults(platform, Path::new("/rman/a1"), inherited, |dir| {
661 dir != Path::new("/usr/local/bin")
662 })
663 }
664
665 #[test]
666 fn macos_puts_existing_homebrew_directories_ahead_of_the_service_path() {
667 let launchd = Inherited {
668 path: Some("/usr/bin:/bin:/usr/sbin:/sbin".into()),
669 has_locale: false,
670 };
671 assert_eq!(
672 defaults(RunnerPlatform::MacOs, &launchd),
673 [
674 (
675 "PATH",
676 OsString::from(
677 "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin"
678 )
679 ),
680 ("LANG", OsString::from(MACOS_DEFAULT_LANG)),
681 ]
682 );
683
684 let login = Inherited {
687 path: Some("/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin".into()),
688 has_locale: true,
689 };
690 assert!(defaults(RunnerPlatform::MacOs, &login).is_empty());
691
692 let bare = Inherited {
694 path: None,
695 has_locale: true,
696 };
697 assert_eq!(
698 defaults(RunnerPlatform::MacOs, &bare),
699 [(
700 "PATH",
701 OsString::from(format!(
702 "/opt/homebrew/bin:/opt/homebrew/sbin:{MACOS_SYSTEM_PATH}"
703 ))
704 )]
705 );
706 }
707
708 #[test]
709 fn windows_gives_each_runner_a_profile_inside_its_attempt() {
710 let windows = defaults(RunnerPlatform::Windows, &Inherited::default());
711 let home = Path::new("/rman/a1").join(RUNNER_HOME_DIR);
712 assert_eq!(
713 windows,
714 [
715 ("USERPROFILE", home.clone().into_os_string()),
716 ("HOME", home.clone().into_os_string()),
717 (
718 "APPDATA",
719 home.join("AppData").join("Roaming").into_os_string()
720 ),
721 (
722 "LOCALAPPDATA",
723 home.join("AppData").join("Local").into_os_string()
724 ),
725 ]
726 );
727 assert!(defaults(RunnerPlatform::Linux, &Inherited::default()).is_empty());
728 }
729
730 #[test]
731 fn runner_env_replaces_a_default_of_the_same_name_and_adds_the_rest() {
732 let file = RunnerEnv::parse("PATH=/custom/bin\nELECTRON_CACHE=/cache/electron\n").unwrap();
733 let defaults = vec![
734 ("PATH", OsString::from("/opt/homebrew/bin:/usr/bin")),
735 ("LANG", OsString::from(MACOS_DEFAULT_LANG)),
736 ];
737 assert_eq!(
738 runner_environment(defaults, Vec::new(), &file),
739 [
740 (
741 OsString::from("PATH"),
742 OsString::from("/opt/homebrew/bin:/usr/bin")
743 ),
744 (OsString::from("LANG"), OsString::from(MACOS_DEFAULT_LANG)),
745 (OsString::from("PATH"), OsString::from("/custom/bin")),
746 (
747 OsString::from("ELECTRON_CACHE"),
748 OsString::from("/cache/electron")
749 ),
750 ]
751 );
752 }
753
754 #[test]
757 fn caches_sit_between_the_platform_defaults_and_runner_env() {
758 let file = RunnerEnv::parse("npm_config_cache=/operator/npm\n").unwrap();
759 let defaults = vec![("HOME", OsString::from("/attempt/home"))];
760 let caches = vec![
761 ("npm_config_cache", OsString::from("/cache/o/r/npm")),
762 ("HOME", OsString::from("/cache-would-never-set-this")),
763 ];
764 let applied = runner_environment(defaults, caches, &file);
765 let last = |name: &str| {
766 applied
767 .iter()
768 .rev()
769 .find(|(n, _)| n == name)
770 .map(|(_, v)| v.clone())
771 };
772 assert_eq!(last("npm_config_cache"), Some("/operator/npm".into()));
773 assert_eq!(last("HOME"), Some("/cache-would-never-set-this".into()));
774 let position = |value: &str| applied.iter().position(|(_, v)| v == value).unwrap();
775 assert!(position("/attempt/home") < position("/cache/o/r/npm"));
776 assert!(position("/cache/o/r/npm") < position("/operator/npm"));
777 }
778}