1use std::ffi::{OsStr, OsString};
38use std::fs;
39use std::io::{self, Write as _};
40use std::path::{Path, PathBuf};
41
42pub const RUNNER_ENV_FILE: &str = "runner.env";
44
45pub const RESERVED_NAMES: [&str; 3] = ["TMPDIR", "TEMP", "TMP"];
48
49pub const RESERVED_PREFIX: &str = "ACTIONS_RUNNER_INPUT_";
53
54pub const MACOS_TOOL_DIRECTORIES: [&str; 3] =
57 ["/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin"];
58
59pub const MACOS_SYSTEM_PATH: &str = "/usr/bin:/bin:/usr/sbin:/sbin";
62
63pub const MACOS_DEFAULT_LANG: &str = "en_US.UTF-8";
67
68#[must_use]
70pub fn path_in(config_dir: &Path) -> PathBuf {
71 config_dir.join(RUNNER_ENV_FILE)
72}
73
74#[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#[derive(Debug, Clone, PartialEq, Eq)]
115enum Line {
116 Text(String),
117 Entry { name: String, value: String },
118}
119
120#[derive(Debug, Clone, Default, PartialEq, Eq)]
122pub struct RunnerEnv {
123 lines: Vec<Line>,
124}
125
126impl RunnerEnv {
127 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 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 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 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 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 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 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 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 #[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 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 #[must_use]
315 pub fn len(&self) -> usize {
316 self.entries().count()
317 }
318
319 #[must_use]
321 pub fn is_empty(&self) -> bool {
322 self.len() == 0
323 }
324}
325
326pub fn split_assignment(raw: &str) -> Result<(&str, &str), RunnerEnvError> {
331 raw.split_once('=')
332 .ok_or(RunnerEnvError::NotAnAssignment { line: None })
333}
334
335fn 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
355fn 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
362pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
378pub enum RunnerPlatform {
379 Windows,
380 MacOs,
381 Linux,
382}
383
384impl RunnerPlatform {
385 #[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
398pub const POWERSHELL_TELEMETRY_OPTOUT: &str = "POWERSHELL_TELEMETRY_OPTOUT";
417
418pub const POWERSHELL_TELEMETRY_OPTOUT_VALUE: &str = "1";
421
422#[derive(Debug, Clone, Default)]
424pub struct Inherited {
425 pub path: Option<OsString>,
426 pub has_locale: bool,
428 pub has_powershell_telemetry_optout: bool,
430 pub home_writable: bool,
436}
437
438impl Inherited {
439 #[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 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
459fn 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 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
477pub const RUNNER_HOME_DIR: &str = "home";
480
481pub const WINDOWS_PROFILE_VARIABLES: [&str; 4] = ["USERPROFILE", "HOME", "APPDATA", "LOCALAPPDATA"];
484
485pub const DIRECTORY_VARIABLES: [&str; 3] = ["HOME", "APPDATA", "LOCALAPPDATA"];
488
489#[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 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
570fn 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#[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 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 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 #[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 #[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 #[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}