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<()> {
314 #[cfg(unix)]
315 {
316 sys::share_tree(self.gid, path)
317 }
318 #[cfg(not(unix))]
319 {
320 let _ = path;
321 Ok(())
322 }
323 }
324
325 #[must_use]
328 pub fn is_shared(&self, metadata: &std::fs::Metadata) -> bool {
329 #[cfg(unix)]
330 {
331 sys::is_shared(self.gid, metadata)
332 }
333 #[cfg(not(unix))]
334 {
335 let _ = metadata;
336 true
337 }
338 }
339}
340
341fn run(program: &str, arguments: &[&str]) -> Result<(), String> {
342 let output = std::process::Command::new(program)
343 .args(arguments)
344 .output()
345 .map_err(|error| format!("cannot run {program}: {error}"))?;
346 if output.status.success() {
347 Ok(())
348 } else {
349 Err(format!(
350 "{program} {} failed: {}",
351 arguments.last().unwrap_or(&""),
352 String::from_utf8_lossy(&output.stderr).trim()
353 ))
354 }
355}
356
357#[cfg(unix)]
358mod sys {
359 use std::io;
360 use std::os::unix::fs::MetadataExt as _;
361 use std::path::Path;
362
363 use super::RunnerAccount;
364
365 pub(super) fn is_shared(gid: u32, metadata: &std::fs::Metadata) -> bool {
366 metadata.is_dir() && metadata.gid() == gid && metadata.mode() & 0o2070 == 0o2070
367 }
368
369 pub(super) fn share_tree(gid: u32, path: &Path) -> io::Result<()> {
372 let mut root = true;
373 super::walk::walk(path, &mut |node| {
374 let below = !std::mem::take(&mut root);
375 let stat = node.stat();
376 let directory = matches!(node, super::walk::Node::Directory { .. });
377 #[allow(clippy::useless_conversion)]
379 let mode = u32::from(stat.st_mode) & 0o7777;
380 if below && directory && stat.st_gid == gid && mode & 0o2070 == 0o2070 {
381 return Ok(false);
382 }
383 if stat.st_gid != gid {
384 node.chown(u32::MAX, gid)?;
385 }
386 let wanted = if directory {
387 mode | 0o2070
388 } else {
389 mode | ((mode & 0o700) >> 3)
391 };
392 if wanted != mode {
393 node.chmod(wanted)?;
394 }
395 if let super::walk::Node::Directory { fd, .. } = node {
396 set_default_acl(fd)?;
397 }
398 Ok(true)
399 })
400 }
401
402 #[cfg(any(test, target_os = "linux"))]
405 pub(super) fn default_acl() -> Vec<u8> {
406 const VERSION: u32 = 2;
407 const UNDEFINED: u32 = u32::MAX;
408 let mut acl = VERSION.to_le_bytes().to_vec();
409 for (tag, permissions) in [(0x01_u16, 7_u16), (0x04, 7), (0x20, 0)] {
411 acl.extend_from_slice(&tag.to_le_bytes());
412 acl.extend_from_slice(&permissions.to_le_bytes());
413 acl.extend_from_slice(&UNDEFINED.to_le_bytes());
414 }
415 acl
416 }
417
418 fn set_default_acl(directory: &std::os::fd::OwnedFd) -> io::Result<()> {
419 #[cfg(target_os = "linux")]
420 match super::walk::set_attribute(directory, c"system.posix_acl_default", &default_acl()) {
421 Err(error) if error.raw_os_error() != Some(libc::ENOTSUP) => return Err(error),
425 _ => {}
426 }
427 #[cfg(not(target_os = "linux"))]
428 let _ = directory;
429 Ok(())
430 }
431
432 pub(super) fn group(name: &str) -> Option<u32> {
433 let name = std::ffi::CString::new(name).ok()?;
434 let mut buffer = vec![0_u8; 4096];
435 loop {
436 let mut entry: libc::group = unsafe { std::mem::zeroed() };
439 let mut found: *mut libc::group = std::ptr::null_mut();
440 let status = unsafe {
442 libc::getgrnam_r(
443 name.as_ptr(),
444 &mut entry,
445 buffer.as_mut_ptr().cast(),
446 buffer.len(),
447 &mut found,
448 )
449 };
450 if status == libc::ERANGE && buffer.len() < 1 << 20 {
451 buffer.resize(buffer.len() * 2, 0);
452 continue;
453 }
454 if status != 0 || found.is_null() {
455 return None;
456 }
457 return Some(entry.gr_gid);
458 }
459 }
460
461 #[cfg(target_os = "linux")]
465 pub(super) fn bounding_set_is_narrowed() -> bool {
466 const CAP_SYS_ADMIN: libc::c_ulong = 21;
467 !in_bounding_set(CAP_SYS_ADMIN)
468 }
469
470 #[cfg(target_os = "linux")]
472 pub(super) fn bounding_set_holds_any() -> bool {
473 (0..64).any(in_bounding_set)
476 }
477
478 #[cfg(target_os = "linux")]
481 pub(super) fn can_switch_account() -> bool {
482 const CAP_CHOWN: libc::c_ulong = 0;
483 const CAP_SETGID: libc::c_ulong = 6;
484 const CAP_SETUID: libc::c_ulong = 7;
485 [CAP_CHOWN, CAP_SETGID, CAP_SETUID]
486 .into_iter()
487 .all(in_bounding_set)
488 }
489
490 #[cfg(target_os = "linux")]
491 fn in_bounding_set(capability: libc::c_ulong) -> bool {
492 unsafe { libc::prctl(libc::PR_CAPBSET_READ, capability, 0, 0, 0) == 1 }
494 }
495
496 pub(super) fn lookup(name: &str) -> Option<RunnerAccount> {
497 let name = std::ffi::CString::new(name).ok()?;
498 let mut buffer = vec![0_u8; 4096];
499 loop {
500 let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
503 let mut found: *mut libc::passwd = std::ptr::null_mut();
504 let status = unsafe {
508 libc::getpwnam_r(
509 name.as_ptr(),
510 &mut entry,
511 buffer.as_mut_ptr().cast(),
512 buffer.len(),
513 &mut found,
514 )
515 };
516 if status == libc::ERANGE && buffer.len() < 1 << 20 {
517 buffer.resize(buffer.len() * 2, 0);
518 continue;
519 }
520 if status != 0 || found.is_null() {
521 return None;
522 }
523 return Some(RunnerAccount {
524 uid: entry.pw_uid,
525 gid: entry.pw_gid,
526 });
527 }
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534
535 #[cfg(unix)]
536 #[test]
537 fn an_account_that_exists_is_found_and_one_that_does_not_is_not() {
538 let root = RunnerAccount::lookup("root").expect("every Unix host has root");
539 assert_eq!(root.uid, 0);
540 assert_eq!(
541 RunnerAccount::lookup("runner-manager-no-such-account-x"),
542 None
543 );
544 }
545
546 #[cfg(unix)]
547 #[test]
548 fn giving_a_tree_to_its_own_owner_changes_nothing_and_never_follows_links() {
549 use std::os::unix::fs::MetadataExt as _;
550
551 let directory = tempfile::tempdir().unwrap();
552 let outside = tempfile::tempdir().unwrap();
553 std::fs::create_dir_all(directory.path().join("a/b")).unwrap();
554 std::fs::write(directory.path().join("a/b/file"), b"x").unwrap();
555 std::os::unix::fs::symlink(outside.path(), directory.path().join("a/link")).unwrap();
556 let metadata = std::fs::metadata(directory.path()).unwrap();
557 let me = RunnerAccount {
558 uid: metadata.uid(),
559 gid: metadata.gid(),
560 };
561 me.give_tree(directory.path()).unwrap();
562 assert!(me.owns(&std::fs::metadata(directory.path().join("a/b/file")).unwrap()));
563
564 if !crate::host_fitness::is_elevated() {
567 let other = RunnerAccount {
568 uid: me.uid + 1,
569 gid: me.gid,
570 };
571 assert!(other.give_tree(directory.path()).is_err());
572 }
573 }
574
575 #[test]
576 fn no_account_is_used_unless_this_is_root_on_linux() {
577 if !(cfg!(target_os = "linux") && crate::host_fitness::is_elevated()) {
578 assert_eq!(RunnerPool::for_this_daemon(), Ok(None));
579 }
580 }
581
582 #[test]
583 fn pool_accounts_are_numbered_from_one_in_the_runner_group() {
584 assert_eq!(pool_account_name(1), "rm-runner-1");
585 assert_eq!(pool_account_name(POOL_SIZE), "rm-runner-32");
586 }
587
588 #[test]
589 fn a_pool_hands_out_its_own_accounts_and_the_legacy_one_only_alone() {
590 let legacy = RunnerAccount { uid: 997, gid: 972 };
591 let first = RunnerAccount { uid: 990, gid: 972 };
592 let mut pool = RunnerPool {
593 gid: 972,
594 accounts: vec![first],
595 legacy: Some(legacy),
596 };
597 let none = std::collections::BTreeSet::new();
598 let held = std::collections::BTreeSet::from([990]);
599 assert_eq!(pool.allocate(None, &none, Default::default), Some(first));
600 assert_eq!(pool.allocate(None, &held, Default::default), None);
601 assert_eq!(pool.allocate(None, &none, || held.clone()), None);
602 assert_eq!(
603 pool.allocate(Some(first), &none, || held.clone()),
604 Some(first)
605 );
606 assert_eq!(pool.account(997), Some(legacy));
607 assert_eq!(pool.account(990), Some(first));
608 assert_eq!(pool.account(0), None);
609 pool.accounts.clear();
610 assert_eq!(pool.allocate(None, &held, Default::default), Some(legacy));
611 }
612
613 #[cfg(unix)]
614 #[test]
615 fn the_default_acl_is_the_kernel_encoding_of_group_rwx() {
616 assert_eq!(
617 sys::default_acl(),
618 [
619 2, 0, 0, 0, 1, 0, 7, 0, 255, 255, 255, 255, 4, 0, 7, 0, 255, 255, 255, 255, 32, 0, 0, 0, 255, 255, 255, 255, ]
624 );
625 }
626
627 #[cfg(target_os = "linux")]
628 #[test]
629 fn sharing_a_tree_opens_it_to_the_group_once_and_never_follows_links() {
630 use std::os::unix::fs::MetadataExt as _;
631
632 let directory = tempfile::tempdir().unwrap();
633 let outside = tempfile::tempdir().unwrap();
634 let root = directory.path().join("cache");
635 std::fs::create_dir_all(root.join("npm/_cacache")).unwrap();
636 std::fs::write(root.join("npm/_cacache/entry"), b"x").unwrap();
637 std::fs::set_permissions(
638 root.join("npm/_cacache/entry"),
639 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
640 )
641 .unwrap();
642 std::os::unix::fs::symlink(outside.path(), root.join("link")).unwrap();
643 let gid = std::fs::metadata(&root).unwrap().gid();
644 let pool = RunnerPool {
645 gid,
646 accounts: Vec::new(),
647 legacy: None,
648 };
649
650 pool.share_tree(&root).unwrap();
651
652 let mode = |path: &Path| std::fs::metadata(path).unwrap().mode() & 0o7777;
653 assert_eq!(mode(&root.join("npm")) & 0o2070, 0o2070);
654 assert_eq!(mode(&root.join("npm/_cacache/entry")), 0o660);
655 assert!(pool.is_shared(&std::fs::metadata(root.join("npm")).unwrap()));
656 assert_eq!(
657 mode(outside.path()) & 0o2000,
658 0,
659 "a link's target is never touched"
660 );
661 }
662}