Skip to main content

runner_manager_platform/
runner_account.rs

1// owner: d1-platform-core
2
3//! The unprivileged accounts a Linux boot service runs its runners as.
4//!
5//! A boot service's daemon runs as `root`, because only `root` can read the
6//! machine-scoped credential. Its runners do not have to, and must not: a job
7//! running as `root` with no capabilities cannot change a file's owner, so
8//! `tar`, npm and `node-gyp` fail on any archive whose entries another account
9//! owns, and with capabilities a hostile workflow would hold them too.
10//!
11//! So the daemon keeps the capabilities it needs to hand a runner its
12//! directories and to signal it, and starts each runner as an account of its
13//! own from a [`RunnerPool`]. One account per concurrent runner is what keeps
14//! jobs apart from each other: a job cannot read another job's workspace, its
15//! runner's credentials or its processes' environment, nor signal them. The
16//! accounts share one group, [`RUNNER_GROUP`], which is how they share the
17//! dependency caches the operator chose to share.
18//! Moving from `root` to another uid clears every capability the kernel would
19//! otherwise pass on, and `NoNewPrivileges=yes` stops a set-user-ID or
20//! file-capability binary from granting one back, so the job runs with none.
21//!
22//! The account has no password, no login shell and no home directory. Each
23//! runner gets a `HOME` inside its own attempt (see
24//! [`crate::runner_env::platform_defaults`]).
25
26#[cfg(unix)]
27mod walk;
28
29use std::io;
30use std::path::Path;
31
32/// The group every runner account belongs to, and the account 0.4.56 ran every
33/// runner as, which a pool still accepts for an attempt that predates it.
34pub const RUNNER_GROUP: &str = "rm-runner";
35
36/// How many runner accounts `service install --start-at boot` creates: the
37/// most runners a host can run at once without two of them sharing an account.
38pub const POOL_SIZE: u32 = 32;
39
40/// The name of the pool's `n`th account, counting from one.
41#[must_use]
42pub fn pool_account_name(n: u32) -> String {
43    format!("{RUNNER_GROUP}-{n}")
44}
45
46/// A resolved account: what a runner is started as and what its directories
47/// are handed to.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct RunnerAccount {
50    /// The account's user id.
51    pub uid: u32,
52    /// Its primary group id.
53    pub gid: u32,
54}
55
56impl RunnerAccount {
57    /// The account called `name`, when it exists.
58    #[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    /// Hands `path` itself to this account, without following a link there.
72    ///
73    /// # Errors
74    ///
75    /// What `lchown` reported.
76    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    /// Hands `path` and everything under it to this account.
89    ///
90    /// Links are handed over themselves and never followed, so a link a
91    /// previous job left pointing outside the tree cannot carry the change
92    /// anywhere else. Below `path`, a directory the account already owns is
93    /// not entered: only the account writes inside one, so nothing there needs
94    /// handing over, and a retained `_work` or an already handed package is
95    /// not walked again.
96    ///
97    /// # Errors
98    ///
99    /// The first error met, after which the rest of the tree is not visited.
100    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    /// Whether this account already owns what `metadata` describes.
122    #[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/// The accounts a Linux boot service's runners run as, one per concurrent
138/// runner, all in [`RUNNER_GROUP`].
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct RunnerPool {
141    /// [`RUNNER_GROUP`]'s id.
142    pub gid: u32,
143    /// The accounts a new attempt may be given, in order of preference.
144    pub accounts: Vec<RunnerAccount>,
145    /// The single account an earlier release ran every runner as: accepted
146    /// for an attempt it already owns, and given to a new one only when the
147    /// pool has no account of its own yet.
148    pub legacy: Option<RunnerAccount>,
149}
150
151impl RunnerPool {
152    /// The pool runners launched by this process run as: `Ok(None)` when they
153    /// run as this process does.
154    ///
155    /// `Some` only for `root` on Linux with the pool present, which is what
156    /// `service install --start-at boot` sets up. A login service, a foreground
157    /// daemon and every other platform keep their runners in their own
158    /// account, as before.
159    ///
160    /// A boot unit's daemon is `root` with a narrowed capability bounding set
161    /// that still holds `CAP_DAC_OVERRIDE` and the rest of
162    /// [`crate::service::BOOT_CAPABILITIES`]. A runner it started as itself
163    /// would keep them, so without the pool it must start none. A `root` with
164    /// every capability -- `sudo runner-manager daemon run` in a terminal --
165    /// keeps its runners as itself, as before.
166    ///
167    /// # Errors
168    ///
169    /// Why no runner may start: the pool is missing under a confined `root`.
170    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            // A unit registered before the pool existed has an empty bounding
177            // set: it can switch to no account, and a runner it starts as
178            // itself holds no capability, so it keeps doing that.
179            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    /// The pool as it exists on this host, or `None` when it has no account
200    /// at all.
201    #[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    /// The pool, with [`RUNNER_GROUP`] and every account up to [`POOL_SIZE`]
228    /// created as system accounts where missing.
229    ///
230    /// # Errors
231    ///
232    /// What `groupadd` or `useradd` reported, when one had to run and failed.
233    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    /// The pool account, or the legacy one, with user id `uid`.
266    #[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    /// The account an attempt runs as. `owner` is the one that already owns
276    /// its directory, kept unless another live attempt holds it (`held`): a
277    /// persistent slot kept since an earlier attempt then moves to a free
278    /// account. Otherwise the first account neither held nor `busy` (which
279    /// is asked only then) is chosen. While the pool has no account of its
280    /// own, every attempt shares the legacy one. `None` when all are taken.
281    #[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    /// Shares `path` and everything under it with every account in the pool:
302    /// the group becomes [`RUNNER_GROUP`], which may read and write it, new
303    /// entries in a directory inherit that group (set-group-ID), and a default
304    /// ACL gives the group write access to what any account creates later,
305    /// whatever umask that account's job runs with. Everyone else may read
306    /// and search it, as under a umask of `022`, but never write it.
307    ///
308    /// A directory already shared is not entered, so a second call over a
309    /// large cache costs a `stat` and a read of its ACL. Links are never
310    /// followed.
311    ///
312    /// # Errors
313    ///
314    /// The first error met, after which the rest of the tree is not visited.
315    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    /// Whether `path` is a directory [`Self::share_tree`] has already
328    /// shared, as this release shares it. A link is not.
329    #[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    /// What a shared directory grants beyond its owner: set-group-ID, the
368    /// group everything, and everyone else read and search.
369    const SHARED_DIRECTORY: u32 = 0o2075;
370
371    /// Whether the directory at `path` is shared already: its group, its
372    /// mode, and the default ACL this release sets. A tree shared by a release
373    /// whose ACL gave everyone else nothing fails the last check, so it is
374    /// walked once more and opened up.
375    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    /// Whether the open `directory`, of group `group` and mode `mode`, is
391    /// shared with `gid` as this release shares it.
392    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    /// Shares `path` and what is under it that is not shared yet. A
397    /// directory below `path` that is already shared is not entered.
398    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            // `mode_t` is narrower than `u32` on macOS.
405            #[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                // The group gets what the owner has, short of the special bits,
420                // and everyone else what the owner may read and run.
421                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    /// The default ACL `user::rwx group::rwx other::r-x`, in the kernel's
434    /// `system.posix_acl_default` encoding.
435    ///
436    /// Everyone else may read and run what is cached, never change it: a
437    /// `container:` job's steps that run as the image's own user are no
438    /// account of this host, and they run the toolchains the runner mounts
439    /// from the tool cache at `/__t`. The runner root keeps every account
440    /// outside the pool from reaching a cache under it on the host.
441    #[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        // (tag, permissions): ACL_USER_OBJ, ACL_GROUP_OBJ, ACL_OTHER.
447        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    /// Whether `directory` carries [`default_acl`], or sits on a filesystem
456    /// that keeps no ACL, where there is none to set.
457    fn has_default_acl(directory: &std::os::fd::OwnedFd) -> bool {
458        #[cfg(target_os = "linux")]
459        {
460            let wanted = default_acl();
461            // One byte more than wanted, so a longer ACL does not compare equal.
462            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            // A filesystem without ACLs still gets the group and the
479            // set-group-ID bit; what one account creates there is then
480            // readable by the others, though not writable.
481            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            // SAFETY: `group` is plain data that `getgrnam_r` fills in, and
494            // zero is a valid value for every field until it does.
495            let mut entry: libc::group = unsafe { std::mem::zeroed() };
496            let mut found: *mut libc::group = std::ptr::null_mut();
497            // SAFETY: as for `getpwnam_r` in `lookup` below.
498            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    /// Whether this process's capability bounding set lacks `CAP_SYS_ADMIN`,
519    /// which only a deliberately confined process -- a systemd unit with a
520    /// `CapabilityBoundingSet=` -- does.
521    #[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    /// Whether this process's bounding set holds any capability at all.
528    #[cfg(target_os = "linux")]
529    pub(super) fn bounding_set_holds_any() -> bool {
530        // CAP_LAST_CAP has grown over kernel releases; an unknown number reads
531        // as an error, which `in_bounding_set` reports as absent.
532        (0..64).any(in_bounding_set)
533    }
534
535    /// Whether this process may start a child as another account and hand it
536    /// its directories: `CAP_CHOWN`, `CAP_SETGID` and `CAP_SETUID`.
537    #[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        // SAFETY: PR_CAPBSET_READ only reads this process's bounding set.
550        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            // SAFETY: `passwd` is plain data that `getpwnam_r` fills in, and
558            // zero is a valid value for every field until it does.
559            let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
560            let mut found: *mut libc::passwd = std::ptr::null_mut();
561            // SAFETY: every pointer is valid for the call: `name` is
562            // NUL-terminated, `buffer` is writable for its length, and `entry`
563            // and `found` are local.
564            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        // Another owner is refused for an unprivileged test process, which
622        // shows the call reaches lchown; root is skipped since it may.
623        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, // version
677                1, 0, 7, 0, 255, 255, 255, 255, // user::rwx
678                4, 0, 7, 0, 255, 255, 255, 255, // group::rwx
679                32, 0, 5, 0, 255, 255, 255, 255, // other::r-x
680            ]
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    /// Whether `path`'s filesystem keeps a default ACL; a test of what the
722    /// ACL does has nothing to show on one that does not.
723    #[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        // SAFETY: `name` and the attribute name are NUL-terminated; a zero
727        // size only asks for the length.
728        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    /// A `container:` job's steps run as an account of the image, which is in
740    /// no group of this host: to the host it is "other". The runner mounts the
741    /// slot's tool cache at `/__t`, so a toolchain a job installed there as the
742    /// container's root must stay readable and executable for "other", or the
743    /// next step that runs it as the image's user fails with
744    /// `env: 'node': Permission denied` (AI-Game-Dev-App, 2026-11-11).
745    #[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        // What `@actions/tool-cache` does: `mkdir -p` the version's folder
765        // (mode 0777) and create the binary with its archive mode, 0755.
766        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    /// 0.4.57 to 0.4.61 shared caches with a default ACL that gave everyone
785    /// else nothing. Such a tree reads as not shared, and sharing it again
786    /// opens what those releases' jobs left in it.
787    #[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            // SAFETY: both names are NUL-terminated and the value is valid
807            // for its length.
808            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        // The top was created by the daemon (umask 022), the rest by jobs.
823        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        // The group gets what the owner has, everyone else read and run.
842        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}