forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
21.4 KiB
1//! User and group id mapping for the sandbox's user namespaces.
2//!
3//! Two modes:
4//! - **single**: the runner's uid and gid map to container 0 and nothing else. Jobs can't
5//! `chown` to other ids.
6//! - **subuid**: container 0 maps to the runner's uid, and container `1..=n` map to the range
7//! granted in `/etc/subuid` and `/etc/subgid`. Writing a map with more than one line needs the
8//! setuid helpers `newuidmap` and `newgidmap` from shadow-utils, so that is what the helper
9//! runs.
10//!
11//! The namespace entry functions ([`enter_mapped_userns`]) must run in a single-threaded process.
12//! The sandbox helpers are single-threaded, and [`enter_mapped_userns`] forks in subuid mode.
13
14use std::ffi::OsString;
15use std::fs::{self, File};
16use std::io::{self, Read, Write};
17use std::os::unix::fs::PermissionsExt;
18use std::path::{Path, PathBuf};
19use std::process::{Command, Stdio};
20
21use nix::errno::Errno;
22use nix::fcntl::OFlag;
23use nix::mount::{MsFlags, mount};
24use nix::sched::{CloneFlags, unshare};
25use nix::sys::wait::{WaitStatus, waitpid};
26use nix::unistd::{ForkResult, Pid, Uid, User, fork, getgid, getpid, getuid, pipe2};
27use serde::{Deserialize, Serialize};
28
29/// The most ids a subordinate range may contribute. This is the usual size of a range.
30const MAX_SUBID_COUNT: u32 = 65536;
31/// Subordinate ranges smaller than this are not used at all.
32const MIN_SUBID_COUNT: u32 = 1000;
33/// Where the setuid mapping tools are looked for, in order.
34const TOOL_DIRS: [&str; 4] = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"];
35
36/// How the sandbox maps ids. Set by `[sandbox] userns` in the runner config.
37#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
38#[serde(rename_all = "lowercase")]
39pub enum UsernsMode {
40 /// Subuid mode when the host supports it, otherwise single.
41 #[default]
42 Auto,
43 /// Only the runner's own uid and gid.
44 Single,
45 /// Subordinate ids. A host that can't provide them is an error, not a fallback.
46 Subuid,
47}
48
49/// The id mapping a sandbox uses. The helper receives it as JSON.
50///
51/// `sub_uid` and `sub_gid` are `(start, count)` ranges on the host. Both are set in subuid mode.
52/// Both must have the same count, so container ids `1..=count` are valid for uids and gids.
53#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
54pub struct IdMap {
55 pub uid: u32,
56 pub gid: u32,
57 pub sub_uid: Option<(u32, u32)>,
58 pub sub_gid: Option<(u32, u32)>,
59}
60
61/// Errors from resolving or entering an id mapping.
62#[derive(Debug, thiserror::Error)]
63pub enum IdMapError {
64 /// Subuid mode was asked for, but the host can't provide it.
65 #[error("subuid mode unavailable: {0}")]
66 Unavailable(String),
67 /// `unshare(CLONE_NEWUSER)` failed.
68 #[error("user namespaces unavailable: {0}")]
69 Namespace(String),
70 /// Writing or mapping ids failed, or making the mounts private.
71 #[error("{0}")]
72 Map(String),
73}
74
75impl IdMap {
76 /// The runner's own uid and gid, with no subordinate ranges.
77 pub fn single() -> Self {
78 Self {
79 uid: getuid().as_raw(),
80 gid: getgid().as_raw(),
81 sub_uid: None,
82 sub_gid: None,
83 }
84 }
85
86 /// Resolves `mode` to a concrete map. See the module docs for the rules.
87 pub fn resolve(mode: UsernsMode) -> Result<Self, IdMapError> {
88 match mode {
89 UsernsMode::Single => Ok(Self::single()),
90 UsernsMode::Subuid => subuid_map().map_err(IdMapError::Unavailable),
91 UsernsMode::Auto => Ok(subuid_map().unwrap_or_else(|reason| {
92 tracing::info!("user namespace ids: using the runner's own id only, {reason}");
93 Self::single()
94 })),
95 }
96 }
97
98 /// Whether the map has subordinate ranges.
99 pub fn is_subuid(&self) -> bool {
100 self.sub_uid.is_some() && self.sub_gid.is_some()
101 }
102
103 /// How many container ids are mapped, including container 0. `1 + n` in subuid mode.
104 pub fn size(&self) -> u32 {
105 match (self.sub_uid, self.sub_gid) {
106 (Some((_, n_uid)), Some((_, n_gid))) => 1 + n_uid.min(n_gid),
107 _ => 1,
108 }
109 }
110
111 /// A short description for logs, for example `subuid 524288+65536`.
112 pub fn describe(&self) -> String {
113 match (self.sub_uid, self.sub_gid) {
114 (Some((start, count)), Some(_)) => format!("subuid {start}+{count}"),
115 _ => format!("single uid {}", self.uid),
116 }
117 }
118}
119
120impl Default for IdMap {
121 fn default() -> Self {
122 Self::single()
123 }
124}
125
126/// Parses `/etc/subuid` or `/etc/subgid` text. It returns the `(start, count)` ranges whose owner
127/// is `user` (by name) or `uid` (by number), in file order. Comments, blank lines and malformed
128/// lines are skipped.
129pub fn parse_subid(content: &str, user: &str, uid: u32) -> Vec<(u32, u32)> {
130 content
131 .lines()
132 .filter_map(|line| {
133 let line = line.trim();
134 if line.is_empty() || line.starts_with('#') {
135 return None;
136 }
137 let mut fields = line.split(':');
138 let (Some(owner), Some(start), Some(count), None) =
139 (fields.next(), fields.next(), fields.next(), fields.next())
140 else {
141 return None;
142 };
143 let matches =
144 (!user.is_empty() && owner == user) || owner.parse::<u32>().ok() == Some(uid);
145 if !matches {
146 return None;
147 }
148 let start: u32 = start.parse().ok()?;
149 let count: u32 = count.parse().ok()?;
150 if count == 0 || start.checked_add(count).is_none() {
151 return None;
152 }
153 Some((start, count))
154 })
155 .collect()
156}
157
158/// The range to use. It is the first one with at least 65536 ids. Failing that, it is the first
159/// one with at least 1000, which the caller reports as a warning. Smaller ranges are not used.
160fn pick_range(ranges: &[(u32, u32)]) -> Option<(u32, u32)> {
161 ranges
162 .iter()
163 .copied()
164 .find(|&(_, count)| count >= MAX_SUBID_COUNT)
165 .or_else(|| {
166 ranges
167 .iter()
168 .copied()
169 .find(|&(_, count)| count >= MIN_SUBID_COUNT)
170 })
171}
172
173/// Builds the subuid map for the current user, or says why it can't.
174fn subuid_map() -> Result<IdMap, String> {
175 for tool in ["newuidmap", "newgidmap"] {
176 if find_tool(tool).is_none() {
177 return Err(format!("{tool} is not installed (shadow-utils)"));
178 }
179 }
180 let uid = getuid().as_raw();
181 let gid = getgid().as_raw();
182 let name = User::from_uid(Uid::from_raw(uid))
183 .ok()
184 .flatten()
185 .map(|user| user.name)
186 .unwrap_or_default();
187 let who = if name.is_empty() {
188 format!("uid {uid}")
189 } else {
190 name.clone()
191 };
192
193 // /etc/subgid entries are also keyed by the user, so the uid is the key there too.
194 let uid_ranges = find_ranges("/etc/subuid", &name, uid)?;
195 let gid_ranges = find_ranges("/etc/subgid", &name, uid)?;
196 let (uid_start, uid_count) = pick_range(&uid_ranges)
197 .ok_or_else(|| format!("no usable range in /etc/subuid for {who}"))?;
198 let (gid_start, gid_count) = pick_range(&gid_ranges)
199 .ok_or_else(|| format!("no usable range in /etc/subgid for {who}"))?;
200 let count = uid_count.min(gid_count).min(MAX_SUBID_COUNT);
201 if count < MAX_SUBID_COUNT {
202 tracing::warn!(
203 count,
204 "the subordinate id range for {who} is smaller than {MAX_SUBID_COUNT}, \
205 so jobs get only {count} ids"
206 );
207 }
208 Ok(IdMap {
209 uid,
210 gid,
211 sub_uid: Some((uid_start, count)),
212 sub_gid: Some((gid_start, count)),
213 })
214}
215
216fn find_ranges(path: &str, user: &str, uid: u32) -> Result<Vec<(u32, u32)>, String> {
217 match fs::read_to_string(path) {
218 Ok(text) => Ok(parse_subid(&text, user, uid)),
219 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
220 Err(e) => Err(format!("cannot read {path}: {e}")),
221 }
222}
223
224fn find_tool(name: &str) -> Option<PathBuf> {
225 TOOL_DIRS
226 .iter()
227 .map(|dir| Path::new(dir).join(name))
228 .find(|path| path.is_file())
229}
230
231/// Enters a new user and mount namespace whose ids are mapped as `map` says. The process is root
232/// inside it, and its mounts are private, so they don't propagate to the host.
233///
234/// Must be called from a single-threaded process, because subuid mode forks. The caller is the
235/// sandbox helper, which has no other threads.
236pub fn enter_mapped_userns(map: &IdMap) -> Result<(), IdMapError> {
237 match (map.sub_uid, map.sub_gid) {
238 (Some((uid_start, count_uid)), Some((gid_start, count_gid))) => {
239 let count = count_uid.min(count_gid).min(MAX_SUBID_COUNT);
240 enter_subuid(map, uid_start, gid_start, count)?;
241 }
242 _ => enter_single(map)?,
243 }
244 mount(
245 None::<&str>,
246 "/",
247 None::<&str>,
248 MsFlags::MS_REC | MsFlags::MS_PRIVATE,
249 None::<&str>,
250 )
251 .map_err(|e| IdMapError::Map(format!("cannot make mounts private: {e}")))
252}
253
254/// Single mode: unshare, deny setgroups, and write the one-line maps directly.
255fn enter_single(map: &IdMap) -> Result<(), IdMapError> {
256 unshare(CloneFlags::CLONE_NEWUSER | CloneFlags::CLONE_NEWNS)
257 .map_err(|e| IdMapError::Namespace(e.to_string()))?;
258 fs::write("/proc/self/setgroups", "deny")
259 .map_err(|e| IdMapError::Map(format!("cannot write setgroups: {e}")))?;
260 fs::write("/proc/self/gid_map", format!("0 {} 1\n", map.gid))
261 .map_err(|e| IdMapError::Map(format!("cannot write gid_map: {e}")))?;
262 fs::write("/proc/self/uid_map", format!("0 {} 1\n", map.uid))
263 .map_err(|e| IdMapError::Map(format!("cannot write uid_map: {e}")))
264}
265
266/// What the forked child needs to write the maps.
267struct MapJob {
268 parent: i32,
269 uid: u32,
270 gid: u32,
271 uid_start: u32,
272 gid_start: u32,
273 count: u32,
274}
275
276/// Subuid mode. The parent unshares, and a forked child writes the maps with `newuidmap` and
277/// `newgidmap`. The child can't do this itself, because the maps belong to the parent's new
278/// namespace.
279///
280/// The child waits on a pipe until the parent has unshared. The parent then writes one byte, and
281/// the child runs the two tools. The child reports its failure through a second pipe, which the
282/// parent reads to EOF before it reaps the child.
283fn enter_subuid(map: &IdMap, uid_start: u32, gid_start: u32, count: u32) -> Result<(), IdMapError> {
284 let pipe_err = |e: Errno| IdMapError::Map(format!("cannot create pipe: {e}"));
285 let (go_r, go_w) = pipe2(OFlag::O_CLOEXEC).map_err(pipe_err)?;
286 let (err_r, err_w) = pipe2(OFlag::O_CLOEXEC).map_err(pipe_err)?;
287 let job = MapJob {
288 parent: getpid().as_raw(),
289 uid: map.uid,
290 gid: map.gid,
291 uid_start,
292 gid_start,
293 count,
294 };
295
296 // SAFETY: the helper is single-threaded (see the module docs), so the child is a copy of a
297 // process with one thread, and no lock held by another thread can block it. The child only
298 // reads a pipe, runs std::process::Command, and exits.
299 match unsafe { fork() }.map_err(|e| IdMapError::Map(format!("cannot fork: {e}")))? {
300 ForkResult::Child => {
301 drop(go_w);
302 drop(err_r);
303 let mut go = File::from(go_r);
304 let mut err = File::from(err_w);
305 let code = match write_maps_in_child(&mut go, &job) {
306 Ok(()) => 0,
307 Err(message) => {
308 let _ = err.write_all(message.as_bytes());
309 1
310 }
311 };
312 std::process::exit(code)
313 }
314 ForkResult::Parent { child } => {
315 drop(go_r);
316 drop(err_w);
317 let mut go_w = File::from(go_w);
318 let mut err_r = File::from(err_r);
319 let signalled = unshare(CloneFlags::CLONE_NEWUSER | CloneFlags::CLONE_NEWNS)
320 .map_err(|e| IdMapError::Namespace(e.to_string()))
321 .and_then(|()| {
322 go_w.write_all(b"1").map_err(|e| {
323 IdMapError::Map(format!("cannot signal the mapping helper: {e}"))
324 })
325 });
326 // Closing the pipe tells the child to exit if it is still waiting.
327 drop(go_w);
328 let mut message = Vec::new();
329 let _ = err_r.read_to_end(&mut message);
330 let status = wait_for_child(child);
331 signalled?;
332 match status {
333 Ok(WaitStatus::Exited(_, 0)) => {}
334 Ok(_) => {
335 return Err(IdMapError::Map(format!(
336 "cannot map user ids: {}",
337 String::from_utf8_lossy(&message).trim()
338 )));
339 }
340 Err(e) => {
341 return Err(IdMapError::Map(format!(
342 "waiting for the mapping helper: {e}"
343 )));
344 }
345 }
346 // newgidmap leaves setgroups allowed. Drop the supplementary groups the process had
347 // before the namespace existed.
348 nix::unistd::setgroups(&[])
349 .map_err(|e| IdMapError::Map(format!("cannot clear groups: {e}")))
350 }
351 }
352}
353
354/// Runs in the forked child. It waits for the parent's byte, then maps the uids and gids.
355fn write_maps_in_child(go: &mut File, job: &MapJob) -> Result<(), String> {
356 let mut byte = [0u8; 1];
357 loop {
358 match go.read(&mut byte) {
359 Ok(1) => break,
360 Ok(_) => return Err("user namespace setup was cancelled".into()),
361 Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
362 Err(e) => return Err(format!("cannot read from the parent: {e}")),
363 }
364 }
365 let parent = job.parent.to_string();
366 let count = job.count.to_string();
367 let uid_start = job.uid_start.to_string();
368 let gid_start = job.gid_start.to_string();
369 let uid = job.uid.to_string();
370 let gid = job.gid.to_string();
371 run_map_tool(
372 "newuidmap",
373 &[&parent, "0", &uid, "1", "1", &uid_start, &count],
374 )?;
375 run_map_tool(
376 "newgidmap",
377 &[&parent, "0", &gid, "1", "1", &gid_start, &count],
378 )
379}
380
381fn run_map_tool(tool: &str, args: &[&str]) -> Result<(), String> {
382 let path = find_tool(tool).ok_or_else(|| format!("{tool} is not installed"))?;
383 let output = Command::new(&path)
384 .args(args)
385 .stdin(Stdio::null())
386 .output()
387 .map_err(|e| format!("cannot run {tool}: {e}"))?;
388 if output.status.success() {
389 Ok(())
390 } else {
391 Err(format!(
392 "{tool} failed ({}): {}",
393 output.status,
394 String::from_utf8_lossy(&output.stderr).trim()
395 ))
396 }
397}
398
399fn wait_for_child(child: Pid) -> Result<WaitStatus, Errno> {
400 loop {
401 match waitpid(child, None) {
402 Err(Errno::EINTR) => continue,
403 other => return other,
404 }
405 }
406}
407
408/// `<exe> __sandbox-rm <path>`, with the [`IdMap`] JSON on stdin. Enters the namespace, makes the
409/// directories under `path` writable, and removes it. Exit 0 on success, or 125 with a
410/// `forge-sandbox:` line on stderr.
411pub fn rm_helper_main(args: &[OsString]) -> i32 {
412 match rm_helper(args) {
413 Ok(()) => 0,
414 Err(message) => {
415 eprintln!("forge-sandbox: {message}");
416 125
417 }
418 }
419}
420
421fn rm_helper(args: &[OsString]) -> Result<(), String> {
422 let [path] = args else {
423 return Err("usage: __sandbox-rm <path>".into());
424 };
425 let path = PathBuf::from(path);
426 if !path.is_absolute() {
427 return Err("path must be absolute".into());
428 }
429 let map: IdMap = serde_json::from_reader(io::stdin().lock())
430 .map_err(|e| format!("cannot read the id map: {e}"))?;
431 enter_mapped_userns(&map).map_err(|e| e.to_string())?;
432 remove_local(&path).map_err(|e| format!("cannot remove {}: {e}", path.display()))
433}
434
435/// Removes `path` (a file, symlink or directory tree) as the current user. Missing is not an
436/// error. If the first attempt fails, the directories under `path` get `u+rwx` and it is retried.
437fn remove_local(path: &Path) -> io::Result<()> {
438 match remove_once(path) {
439 Ok(()) => Ok(()),
440 Err(first) => {
441 let is_dir = fs::symlink_metadata(path).is_ok_and(|m| m.is_dir());
442 if !is_dir {
443 return Err(first);
444 }
445 make_dirs_writable(path)?;
446 remove_once(path)
447 }
448 }
449}
450
451fn remove_once(path: &Path) -> io::Result<()> {
452 match fs::symlink_metadata(path) {
453 Ok(meta) if meta.is_dir() => fs::remove_dir_all(path),
454 Ok(_) => fs::remove_file(path),
455 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
456 Err(e) => Err(e),
457 }
458}
459
460/// Adds `u+rwx` to every directory under `root`, without following symlinks.
461fn make_dirs_writable(root: &Path) -> io::Result<()> {
462 let mut stack = vec![root.to_owned()];
463 while let Some(dir) = stack.pop() {
464 let mode = fs::symlink_metadata(&dir)?.permissions().mode() & 0o7777;
465 if mode & 0o700 != 0o700 {
466 fs::set_permissions(&dir, fs::Permissions::from_mode(mode | 0o700))?;
467 }
468 for entry in fs::read_dir(&dir)? {
469 let entry = entry?;
470 if entry.file_type()?.is_dir() {
471 stack.push(entry.path());
472 }
473 }
474 }
475 Ok(())
476}
477
478/// Removes `path` when its files may be owned by subordinate ids. Single mode removes it
479/// directly. Subuid mode runs `<exe> __sandbox-rm <path>` with the map on stdin, because the
480/// runner can't delete files owned by ids it doesn't have. A missing path is `Ok`.
481pub fn remove_tree(map: &IdMap, path: &Path, exe: &Path) -> io::Result<()> {
482 if !map.is_subuid() {
483 return remove_local(path);
484 }
485 match fs::symlink_metadata(path) {
486 Ok(_) => {}
487 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
488 Err(e) => return Err(e),
489 }
490 let json = serde_json::to_vec(map).map_err(io::Error::other)?;
491 let mut child = Command::new(exe)
492 .arg("__sandbox-rm")
493 .arg(path)
494 .stdin(Stdio::piped())
495 .stdout(Stdio::null())
496 .stderr(Stdio::piped())
497 .spawn()?;
498 {
499 // The helper reads stdin to EOF, so the pipe must be closed before waiting.
500 let mut stdin = child
501 .stdin
502 .take()
503 .ok_or_else(|| io::Error::other("no stdin for the removal helper"))?;
504 stdin.write_all(&json)?;
505 }
506 let output = child.wait_with_output()?;
507 if output.status.success() {
508 Ok(())
509 } else {
510 Err(io::Error::other(format!(
511 "cannot remove {}: {}",
512 path.display(),
513 String::from_utf8_lossy(&output.stderr).trim()
514 )))
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 /// Whether `unshare --map-auto` works here. The tests that need subordinate ids skip when it
523 /// doesn't.
524 fn map_auto_works() -> bool {
525 Command::new("unshare")
526 .args(["--map-auto", "--map-root-user", "true"])
527 .stdout(Stdio::null())
528 .stderr(Stdio::null())
529 .status()
530 .is_ok_and(|s| s.success())
531 }
532
533 #[test]
534 fn parse_subid_matches_name_and_uid() {
535 let text = "# comment\n\nalice:100000:65536\ntahinli:524288:65536\n\
536 1000:200000:1000\nbob:x:5\nbad line\ncarol:1:2:3\nzero:9:0\n";
537 assert_eq!(
538 parse_subid(text, "tahinli", 1000),
539 vec![(524288, 65536), (200000, 1000)]
540 );
541 // With no name, only the numeric owner matches.
542 assert_eq!(parse_subid(text, "", 1000), vec![(200000, 1000)]);
543 assert_eq!(parse_subid(text, "carol", 1), Vec::new());
544 assert_eq!(parse_subid(text, "nobody", 4242), Vec::new());
545 }
546
547 #[test]
548 fn parse_subid_skips_ranges_that_overflow() {
549 let text = "tahinli:4294967295:2\ntahinli:10:5\n";
550 assert_eq!(parse_subid(text, "tahinli", 1000), vec![(10, 5)]);
551 }
552
553 #[test]
554 fn pick_range_prefers_full_ranges() {
555 assert_eq!(pick_range(&[(1, 500), (2, 1000)]), Some((2, 1000)));
556 assert_eq!(
557 pick_range(&[(1, 500), (9, 65536), (3, 70000)]),
558 Some((9, 65536))
559 );
560 assert_eq!(pick_range(&[(1, 500)]), None);
561 assert_eq!(pick_range(&[]), None);
562 }
563
564 #[test]
565 fn size_and_describe() {
566 let single = IdMap {
567 uid: 1000,
568 gid: 1000,
569 sub_uid: None,
570 sub_gid: None,
571 };
572 assert!(!single.is_subuid());
573 assert_eq!(single.size(), 1);
574 assert_eq!(single.describe(), "single uid 1000");
575
576 let sub = IdMap {
577 uid: 1000,
578 gid: 1000,
579 sub_uid: Some((524288, 65536)),
580 sub_gid: Some((524288, 65536)),
581 };
582 assert!(sub.is_subuid());
583 assert_eq!(sub.size(), 65537);
584 assert_eq!(sub.describe(), "subuid 524288+65536");
585
586 let uneven = IdMap {
587 sub_gid: Some((600000, 10)),
588 ..sub
589 };
590 assert_eq!(uneven.size(), 11);
591 }
592
593 #[test]
594 fn default_is_single() {
595 let map = IdMap::default();
596 assert_eq!(map, IdMap::single());
597 assert!(!map.is_subuid());
598 }
599
600 #[test]
601 fn auto_resolves_to_subuid_on_this_host() {
602 if !map_auto_works() {
603 eprintln!("skipping: unshare --map-auto is not available");
604 return;
605 }
606 let map = IdMap::resolve(UsernsMode::Auto).expect("auto never fails");
607 assert!(map.is_subuid(), "expected subuid, got {}", map.describe());
608 }
609}