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