1#[cfg(unix)]
27mod walk;
28
29use std::io;
30use std::path::Path;
31
32pub const RUNNER_GROUP: &str = "rm-runner";
35
36pub const POOL_SIZE: u32 = 32;
39
40#[must_use]
42pub fn pool_account_name(n: u32) -> String {
43 format!("{RUNNER_GROUP}-{n}")
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct RunnerAccount {
50 pub uid: u32,
52 pub gid: u32,
54}
55
56impl RunnerAccount {
57 #[must_use]
59 pub fn lookup(name: &str) -> Option<Self> {
60 #[cfg(unix)]
61 {
62 sys::lookup(name)
63 }
64 #[cfg(not(unix))]
65 {
66 let _ = name;
67 None
68 }
69 }
70
71 pub fn give(&self, path: &Path) -> io::Result<()> {
77 #[cfg(unix)]
78 {
79 std::os::unix::fs::lchown(path, Some(self.uid), Some(self.gid))
80 }
81 #[cfg(not(unix))]
82 {
83 let _ = path;
84 Ok(())
85 }
86 }
87
88 pub fn give_tree(&self, path: &Path) -> io::Result<()> {
101 #[cfg(unix)]
102 {
103 let mut root = true;
104 walk::walk(path, &mut |node| {
105 let below = !std::mem::take(&mut root);
106 let stat = node.stat();
107 let owned = stat.st_uid == self.uid && stat.st_gid == self.gid;
108 if !owned {
109 node.chown(self.uid, self.gid)?;
110 }
111 Ok(!(below && owned))
112 })
113 }
114 #[cfg(not(unix))]
115 {
116 let _ = path;
117 Ok(())
118 }
119 }
120
121 #[must_use]
123 pub fn owns(&self, metadata: &std::fs::Metadata) -> bool {
124 #[cfg(unix)]
125 {
126 use std::os::unix::fs::MetadataExt as _;
127 metadata.uid() == self.uid && metadata.gid() == self.gid
128 }
129 #[cfg(not(unix))]
130 {
131 let _ = metadata;
132 true
133 }
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct RunnerPool {
141 pub gid: u32,
143 pub accounts: Vec<RunnerAccount>,
145 pub legacy: Option<RunnerAccount>,
149}
150
151impl RunnerPool {
152 pub fn for_this_daemon() -> Result<Option<Self>, String> {
171 #[cfg(target_os = "linux")]
172 {
173 if !crate::host_fitness::is_elevated() {
174 return Ok(None);
175 }
176 if sys::bounding_set_is_narrowed() && !sys::bounding_set_holds_any() {
180 return Ok(None);
181 }
182 if sys::can_switch_account()
183 && let Some(pool) = Self::lookup()
184 {
185 return Ok(Some(pool));
186 }
187 if sys::bounding_set_is_narrowed() {
188 return Err(format!(
189 "the {RUNNER_GROUP} accounts that this service runs jobs as do not exist, \
190 or the service lacks the capabilities to start jobs as them, so no runner \
191 starts rather than run as root with the service's capabilities. Run \
192 `sudo runner-manager service install --start-at boot` to set both up"
193 ));
194 }
195 }
196 Ok(None)
197 }
198
199 #[must_use]
202 pub fn lookup() -> Option<Self> {
203 let gid = Self::group_id()?;
204 let accounts = (1..=POOL_SIZE)
205 .filter_map(|n| RunnerAccount::lookup(&pool_account_name(n)))
206 .filter(|account| account.gid == gid)
207 .collect::<Vec<_>>();
208 let legacy = RunnerAccount::lookup(RUNNER_GROUP).filter(|account| account.gid == gid);
209 (!accounts.is_empty() || legacy.is_some()).then_some(Self {
210 gid,
211 accounts,
212 legacy,
213 })
214 }
215
216 fn group_id() -> Option<u32> {
217 #[cfg(unix)]
218 {
219 sys::group(RUNNER_GROUP)
220 }
221 #[cfg(not(unix))]
222 {
223 None
224 }
225 }
226
227 pub fn ensure() -> Result<Self, String> {
234 if Self::group_id().is_none() {
235 run("groupadd", &["--system", RUNNER_GROUP])?;
236 }
237 for n in 1..=POOL_SIZE {
238 let name = pool_account_name(n);
239 if RunnerAccount::lookup(&name).is_none() {
240 run(
241 "useradd",
242 &[
243 "--system",
244 "--gid",
245 RUNNER_GROUP,
246 "--no-create-home",
247 "--home-dir",
248 "/nonexistent",
249 "--shell",
250 "/usr/sbin/nologin",
251 "--comment",
252 "runner-manager job runner",
253 &name,
254 ],
255 )?;
256 }
257 }
258 Self::lookup()
259 .filter(|pool| pool.accounts.len() == POOL_SIZE as usize)
260 .ok_or_else(|| {
261 format!("the {RUNNER_GROUP} accounts are incomplete after creating them")
262 })
263 }
264
265 #[must_use]
267 pub fn account(&self, uid: u32) -> Option<RunnerAccount> {
268 self.accounts
269 .iter()
270 .chain(self.legacy.iter())
271 .find(|account| account.uid == uid)
272 .copied()
273 }
274
275 #[must_use]
282 pub fn allocate(
283 &self,
284 owner: Option<RunnerAccount>,
285 held: &std::collections::BTreeSet<u32>,
286 busy: impl FnOnce() -> std::collections::BTreeSet<u32>,
287 ) -> Option<RunnerAccount> {
288 if self.accounts.is_empty() {
289 return owner.or(self.legacy);
290 }
291 if let Some(owner) = owner.filter(|owner| !held.contains(&owner.uid)) {
292 return Some(owner);
293 }
294 let busy = busy();
295 self.accounts
296 .iter()
297 .copied()
298 .find(|account| !held.contains(&account.uid) && !busy.contains(&account.uid))
299 }
300
301 pub fn share_tree(&self, path: &Path) -> io::Result<()> {
316 #[cfg(unix)]
317 {
318 sys::share_tree(self.gid, path)
319 }
320 #[cfg(not(unix))]
321 {
322 let _ = path;
323 Ok(())
324 }
325 }
326
327 #[must_use]
330 pub fn is_shared(&self, path: &Path) -> bool {
331 #[cfg(unix)]
332 {
333 sys::is_shared(self.gid, path)
334 }
335 #[cfg(not(unix))]
336 {
337 let _ = path;
338 true
339 }
340 }
341}
342
343fn run(program: &str, arguments: &[&str]) -> Result<(), String> {
344 let output = std::process::Command::new(program)
345 .args(arguments)
346 .output()
347 .map_err(|error| format!("cannot run {program}: {error}"))?;
348 if output.status.success() {
349 Ok(())
350 } else {
351 Err(format!(
352 "{program} {} failed: {}",
353 arguments.last().unwrap_or(&""),
354 String::from_utf8_lossy(&output.stderr).trim()
355 ))
356 }
357}
358
359#[cfg(unix)]
360mod sys {
361 use std::io;
362 use std::os::unix::fs::MetadataExt as _;
363 use std::path::Path;
364
365 use super::RunnerAccount;
366
367 const SHARED_DIRECTORY: u32 = 0o2075;
370
371 pub(super) fn is_shared(gid: u32, path: &Path) -> bool {
376 use std::os::unix::fs::OpenOptionsExt as _;
377 let Ok(directory) = std::fs::File::options()
378 .read(true)
379 .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW)
380 .open(path)
381 else {
382 return false;
383 };
384 let Ok(metadata) = directory.metadata() else {
385 return false;
386 };
387 shared(gid, &directory.into(), metadata.gid(), metadata.mode())
388 }
389
390 fn shared(gid: u32, directory: &std::os::fd::OwnedFd, group: u32, mode: u32) -> bool {
393 group == gid && mode & SHARED_DIRECTORY == SHARED_DIRECTORY && has_default_acl(directory)
394 }
395
396 pub(super) fn share_tree(gid: u32, path: &Path) -> io::Result<()> {
399 let mut root = true;
400 super::walk::walk(path, &mut |node| {
401 let below = !std::mem::take(&mut root);
402 let stat = node.stat();
403 let directory = matches!(node, super::walk::Node::Directory { .. });
404 #[allow(clippy::useless_conversion)]
406 let mode = u32::from(stat.st_mode) & 0o7777;
407 if below
408 && let super::walk::Node::Directory { fd, .. } = node
409 && shared(gid, fd, stat.st_gid, mode)
410 {
411 return Ok(false);
412 }
413 if stat.st_gid != gid {
414 node.chown(u32::MAX, gid)?;
415 }
416 let wanted = if directory {
417 mode | SHARED_DIRECTORY
418 } else {
419 mode | ((mode & 0o700) >> 3) | ((mode & 0o500) >> 6)
422 };
423 if wanted != mode {
424 node.chmod(wanted)?;
425 }
426 if let super::walk::Node::Directory { fd, .. } = node {
427 set_default_acl(fd)?;
428 }
429 Ok(true)
430 })
431 }
432
433 #[cfg(any(test, target_os = "linux"))]
442 pub(super) fn default_acl() -> Vec<u8> {
443 const VERSION: u32 = 2;
444 const UNDEFINED: u32 = u32::MAX;
445 let mut acl = VERSION.to_le_bytes().to_vec();
446 for (tag, permissions) in [(0x01_u16, 7_u16), (0x04, 7), (0x20, 5)] {
448 acl.extend_from_slice(&tag.to_le_bytes());
449 acl.extend_from_slice(&permissions.to_le_bytes());
450 acl.extend_from_slice(&UNDEFINED.to_le_bytes());
451 }
452 acl
453 }
454
455 fn has_default_acl(directory: &std::os::fd::OwnedFd) -> bool {
458 #[cfg(target_os = "linux")]
459 {
460 let wanted = default_acl();
461 let mut value = vec![0_u8; wanted.len() + 1];
463 match super::walk::attribute(directory, c"system.posix_acl_default", &mut value) {
464 Ok(length) => value[..length] == wanted[..],
465 Err(error) => error.raw_os_error() == Some(libc::ENOTSUP),
466 }
467 }
468 #[cfg(not(target_os = "linux"))]
469 {
470 let _ = directory;
471 true
472 }
473 }
474
475 fn set_default_acl(directory: &std::os::fd::OwnedFd) -> io::Result<()> {
476 #[cfg(target_os = "linux")]
477 match super::walk::set_attribute(directory, c"system.posix_acl_default", &default_acl()) {
478 Err(error) if error.raw_os_error() != Some(libc::ENOTSUP) => return Err(error),
482 _ => {}
483 }
484 #[cfg(not(target_os = "linux"))]
485 let _ = directory;
486 Ok(())
487 }
488
489 pub(super) fn group(name: &str) -> Option<u32> {
490 let name = std::ffi::CString::new(name).ok()?;
491 let mut buffer = vec![0_u8; 4096];
492 loop {
493 let mut entry: libc::group = unsafe { std::mem::zeroed() };
496 let mut found: *mut libc::group = std::ptr::null_mut();
497 let status = unsafe {
499 libc::getgrnam_r(
500 name.as_ptr(),
501 &mut entry,
502 buffer.as_mut_ptr().cast(),
503 buffer.len(),
504 &mut found,
505 )
506 };
507 if status == libc::ERANGE && buffer.len() < 1 << 20 {
508 buffer.resize(buffer.len() * 2, 0);
509 continue;
510 }
511 if status != 0 || found.is_null() {
512 return None;
513 }
514 return Some(entry.gr_gid);
515 }
516 }
517
518 #[cfg(target_os = "linux")]
522 pub(super) fn bounding_set_is_narrowed() -> bool {
523 const CAP_SYS_ADMIN: libc::c_ulong = 21;
524 !in_bounding_set(CAP_SYS_ADMIN)
525 }
526
527 #[cfg(target_os = "linux")]
529 pub(super) fn bounding_set_holds_any() -> bool {
530 (0..64).any(in_bounding_set)
533 }
534
535 #[cfg(target_os = "linux")]
538 pub(super) fn can_switch_account() -> bool {
539 const CAP_CHOWN: libc::c_ulong = 0;
540 const CAP_SETGID: libc::c_ulong = 6;
541 const CAP_SETUID: libc::c_ulong = 7;
542 [CAP_CHOWN, CAP_SETGID, CAP_SETUID]
543 .into_iter()
544 .all(in_bounding_set)
545 }
546
547 #[cfg(target_os = "linux")]
548 fn in_bounding_set(capability: libc::c_ulong) -> bool {
549 unsafe { libc::prctl(libc::PR_CAPBSET_READ, capability, 0, 0, 0) == 1 }
551 }
552
553 pub(super) fn lookup(name: &str) -> Option<RunnerAccount> {
554 let name = std::ffi::CString::new(name).ok()?;
555 let mut buffer = vec![0_u8; 4096];
556 loop {
557 let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
560 let mut found: *mut libc::passwd = std::ptr::null_mut();
561 let status = unsafe {
565 libc::getpwnam_r(
566 name.as_ptr(),
567 &mut entry,
568 buffer.as_mut_ptr().cast(),
569 buffer.len(),
570 &mut found,
571 )
572 };
573 if status == libc::ERANGE && buffer.len() < 1 << 20 {
574 buffer.resize(buffer.len() * 2, 0);
575 continue;
576 }
577 if status != 0 || found.is_null() {
578 return None;
579 }
580 return Some(RunnerAccount {
581 uid: entry.pw_uid,
582 gid: entry.pw_gid,
583 });
584 }
585 }
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591
592 #[cfg(unix)]
593 #[test]
594 fn an_account_that_exists_is_found_and_one_that_does_not_is_not() {
595 let root = RunnerAccount::lookup("root").expect("every Unix host has root");
596 assert_eq!(root.uid, 0);
597 assert_eq!(
598 RunnerAccount::lookup("runner-manager-no-such-account-x"),
599 None
600 );
601 }
602
603 #[cfg(unix)]
604 #[test]
605 fn giving_a_tree_to_its_own_owner_changes_nothing_and_never_follows_links() {
606 use std::os::unix::fs::MetadataExt as _;
607
608 let directory = tempfile::tempdir().unwrap();
609 let outside = tempfile::tempdir().unwrap();
610 std::fs::create_dir_all(directory.path().join("a/b")).unwrap();
611 std::fs::write(directory.path().join("a/b/file"), b"x").unwrap();
612 std::os::unix::fs::symlink(outside.path(), directory.path().join("a/link")).unwrap();
613 let metadata = std::fs::metadata(directory.path()).unwrap();
614 let me = RunnerAccount {
615 uid: metadata.uid(),
616 gid: metadata.gid(),
617 };
618 me.give_tree(directory.path()).unwrap();
619 assert!(me.owns(&std::fs::metadata(directory.path().join("a/b/file")).unwrap()));
620
621 if !crate::host_fitness::is_elevated() {
624 let other = RunnerAccount {
625 uid: me.uid + 1,
626 gid: me.gid,
627 };
628 assert!(other.give_tree(directory.path()).is_err());
629 }
630 }
631
632 #[test]
633 fn no_account_is_used_unless_this_is_root_on_linux() {
634 if !(cfg!(target_os = "linux") && crate::host_fitness::is_elevated()) {
635 assert_eq!(RunnerPool::for_this_daemon(), Ok(None));
636 }
637 }
638
639 #[test]
640 fn pool_accounts_are_numbered_from_one_in_the_runner_group() {
641 assert_eq!(pool_account_name(1), "rm-runner-1");
642 assert_eq!(pool_account_name(POOL_SIZE), "rm-runner-32");
643 }
644
645 #[test]
646 fn a_pool_hands_out_its_own_accounts_and_the_legacy_one_only_alone() {
647 let legacy = RunnerAccount { uid: 997, gid: 972 };
648 let first = RunnerAccount { uid: 990, gid: 972 };
649 let mut pool = RunnerPool {
650 gid: 972,
651 accounts: vec![first],
652 legacy: Some(legacy),
653 };
654 let none = std::collections::BTreeSet::new();
655 let held = std::collections::BTreeSet::from([990]);
656 assert_eq!(pool.allocate(None, &none, Default::default), Some(first));
657 assert_eq!(pool.allocate(None, &held, Default::default), None);
658 assert_eq!(pool.allocate(None, &none, || held.clone()), None);
659 assert_eq!(
660 pool.allocate(Some(first), &none, || held.clone()),
661 Some(first)
662 );
663 assert_eq!(pool.account(997), Some(legacy));
664 assert_eq!(pool.account(990), Some(first));
665 assert_eq!(pool.account(0), None);
666 pool.accounts.clear();
667 assert_eq!(pool.allocate(None, &held, Default::default), Some(legacy));
668 }
669
670 #[cfg(unix)]
671 #[test]
672 fn the_default_acl_is_the_kernel_encoding_of_group_rwx_other_rx() {
673 assert_eq!(
674 sys::default_acl(),
675 [
676 2, 0, 0, 0, 1, 0, 7, 0, 255, 255, 255, 255, 4, 0, 7, 0, 255, 255, 255, 255, 32, 0, 5, 0, 255, 255, 255, 255, ]
681 );
682 }
683
684 #[cfg(target_os = "linux")]
685 #[test]
686 fn sharing_a_tree_opens_it_to_the_group_once_and_never_follows_links() {
687 use std::os::unix::fs::MetadataExt as _;
688
689 let directory = tempfile::tempdir().unwrap();
690 let outside = tempfile::tempdir().unwrap();
691 let root = directory.path().join("cache");
692 std::fs::create_dir_all(root.join("npm/_cacache")).unwrap();
693 std::fs::write(root.join("npm/_cacache/entry"), b"x").unwrap();
694 std::fs::set_permissions(
695 root.join("npm/_cacache/entry"),
696 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
697 )
698 .unwrap();
699 std::os::unix::fs::symlink(outside.path(), root.join("link")).unwrap();
700 let gid = std::fs::metadata(&root).unwrap().gid();
701 let pool = RunnerPool {
702 gid,
703 accounts: Vec::new(),
704 legacy: None,
705 };
706
707 pool.share_tree(&root).unwrap();
708
709 let mode = |path: &Path| std::fs::metadata(path).unwrap().mode() & 0o7777;
710 assert_eq!(mode(&root.join("npm")) & 0o2070, 0o2070);
711 assert_eq!(mode(&root.join("npm/_cacache/entry")), 0o664);
712 assert!(pool.is_shared(&root.join("npm")));
713 assert!(!pool.is_shared(&root.join("link")));
714 assert_eq!(
715 mode(outside.path()) & 0o2000,
716 0,
717 "a link's target is never touched"
718 );
719 }
720
721 #[cfg(target_os = "linux")]
724 fn keeps_default_acl(path: &Path) -> bool {
725 let name = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).unwrap();
726 let size = unsafe {
729 libc::getxattr(
730 name.as_ptr(),
731 c"system.posix_acl_default".as_ptr(),
732 std::ptr::null_mut(),
733 0,
734 )
735 };
736 size > 0
737 }
738
739 #[cfg(target_os = "linux")]
746 #[test]
747 fn what_a_job_creates_in_a_shared_cache_stays_readable_by_every_account() {
748 use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _};
749
750 let directory = tempfile::tempdir().unwrap();
751 let root = directory.path().join("tool-cache");
752 std::fs::create_dir(&root).unwrap();
753 let pool = RunnerPool {
754 gid: std::fs::metadata(&root).unwrap().gid(),
755 accounts: Vec::new(),
756 legacy: None,
757 };
758 pool.share_tree(&root).unwrap();
759 if !keeps_default_acl(&root) {
760 eprintln!("skipped: {} keeps no default ACL", root.display());
761 return;
762 }
763
764 let bin = root.join("node/24.22.2/x64/bin");
767 std::fs::create_dir_all(&bin).unwrap();
768 drop(
769 std::fs::OpenOptions::new()
770 .write(true)
771 .create_new(true)
772 .mode(0o755)
773 .open(bin.join("node"))
774 .unwrap(),
775 );
776
777 let mode = |path: &Path| std::fs::metadata(path).unwrap().mode() & 0o7777;
778 for path in [root.join("node"), root.join("node/24.22.2"), bin.clone()] {
779 assert_eq!(mode(&path), 0o2775, "{}", path.display());
780 }
781 assert_eq!(mode(&bin.join("node")), 0o755);
782 }
783
784 #[cfg(target_os = "linux")]
788 #[test]
789 fn a_cache_shared_by_an_earlier_release_is_opened_up_on_the_next_launch() {
790 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
791
792 let directory = tempfile::tempdir().unwrap();
793 let root = directory.path().join("tool-cache");
794 let bin = root.join("node/24.22.2/x64/bin");
795 std::fs::create_dir_all(&bin).unwrap();
796 std::fs::write(bin.join("node"), b"x").unwrap();
797 let earlier_acl: &[u8] = &[
798 2, 0, 0, 0, 1, 0, 7, 0, 255, 255, 255, 255, 4, 0, 7, 0, 255, 255, 255, 255, 32, 0, 0,
799 0, 255, 255, 255, 255,
800 ];
801 let set_mode = |path: &Path, mode: u32| {
802 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
803 };
804 for path in [&root, &root.join("node"), &root.join("node/24.22.2")] {
805 let name = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).unwrap();
806 let status = unsafe {
809 libc::setxattr(
810 name.as_ptr(),
811 c"system.posix_acl_default".as_ptr(),
812 earlier_acl.as_ptr().cast(),
813 earlier_acl.len(),
814 0,
815 )
816 };
817 if status != 0 {
818 eprintln!("skipped: {} keeps no default ACL", path.display());
819 return;
820 }
821 }
822 set_mode(&root, 0o2775);
824 for path in [root.join("node"), root.join("node/24.22.2")] {
825 set_mode(&path, 0o2770);
826 }
827 set_mode(&bin.join("node"), 0o750);
828 let pool = RunnerPool {
829 gid: std::fs::metadata(&root).unwrap().gid(),
830 accounts: Vec::new(),
831 legacy: None,
832 };
833
834 assert!(!pool.is_shared(&root));
835 pool.share_tree(&root).unwrap();
836
837 let mode = |path: &Path| std::fs::metadata(path).unwrap().mode() & 0o7777;
838 for path in [root.join("node"), root.join("node/24.22.2"), bin.clone()] {
839 assert_eq!(mode(&path), 0o2775, "{}", path.display());
840 }
841 assert_eq!(mode(&bin.join("node")), 0o775);
843 assert!(pool.is_shared(&root));
844 assert!(pool.is_shared(&root.join("node/24.22.2")));
845 }
846}