Skip to main content

nucleus/isolation/
usermap.rs

1use crate::error::{NucleusError, Result};
2use std::fs;
3use std::path::{Path, PathBuf};
4use tracing::{debug, info, warn};
5
6/// A delegated subordinate ID range parsed from `/etc/subuid` or `/etc/subgid`.
7///
8/// Mirrors the `login.subuid` / `login.subgid` scheme used by Docker, Podman,
9/// and `newuidmap`/`newgidmap`: `<user>:<start>:<count>`.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub struct SubidRange {
12    pub start: u32,
13    pub count: u32,
14}
15
16impl SubidRange {
17    /// One-past-the-last host id in the range (exclusive).
18    pub fn end(&self) -> u32 {
19        self.start.saturating_add(self.count)
20    }
21
22    /// True if `host_id` falls within this delegated range.
23    pub fn contains_host(&self, host_id: u32) -> bool {
24        host_id >= self.start && host_id < self.end()
25    }
26}
27
28/// Parse `/etc/subuid` (or `/etc/subgid`) for the given login name and numeric
29/// uid, returning the first matching `name:start:count` range.
30///
31/// Lookup order matches shadow-utils / `newuidmap`:
32///   1. exact login-name match (e.g. `dev:100000:65536`)
33///   2. numeric uid match (e.g. `1000:100000:65536`)
34///
35/// Docker/Podman rootless both rely on this file, so reading it makes Nucleus's
36/// rootless userns drop-in compatible with an existing rootless setup.
37pub fn subid_range_for(path: &str, username: &str, uid: u32) -> Option<SubidRange> {
38    let contents = fs::read_to_string(path).ok()?;
39    let uid_str = uid.to_string();
40    // Prefer a login-name match, then a numeric-uid match.
41    for by in [username, uid_str.as_str()] {
42        for line in contents.lines() {
43            let mut it = line.split(':');
44            match (it.next(), it.next(), it.next()) {
45                (Some(name), Some(start), Some(count))
46                    if name == by && !name.is_empty() =>
47                {
48                    if let (Ok(start), Ok(count)) =
49                        (start.parse::<u32>(), count.parse::<u32>())
50                    {
51                        if count > 0 {
52                            return Some(SubidRange { start, count });
53                        }
54                    }
55                }
56                _ => {}
57            }
58        }
59    }
60    None
61}
62
63/// The subordinate UID range delegated to the calling user via `/etc/subuid`.
64pub fn subuid_range_for_user(username: &str, uid: u32) -> Option<SubidRange> {
65    subid_range_for("/etc/subuid", username, uid)
66}
67
68/// The subordinate GID range delegated to the calling user via `/etc/subgid`.
69pub fn subgid_range_for_user(username: &str, uid: u32) -> Option<SubidRange> {
70    subid_range_for("/etc/subgid", username, uid)
71}
72
73/// Container-id ranges visible in our *current* user namespace.
74///
75/// Returns `None` when we are NOT inside a non-trivial user namespace (host
76/// root with the identity map, or no userns at all), so callers fall back to
77/// the historic strict effective-uid ownership check. Otherwise returns the
78/// `[container_start, count)` ranges read from `/proc/self/uid_map`.
79///
80/// This lets filesystem security checks stay correct under `keep-id` / `auto`
81/// mappings, where files owned by the calling user appear as a *non-zero*
82/// container uid (e.g. host uid 1000 -> container uid 1000) and a naive
83/// `owner == effective_uid` comparison wrongly rejects them.
84pub fn current_userns_container_ranges() -> Option<Vec<(u32, u32)>> {
85    let contents = fs::read_to_string("/proc/self/uid_map").ok()?;
86    let mut ranges = Vec::new();
87    let mut identity_root = false;
88    for line in contents.lines() {
89        let mut it = line.split_whitespace();
90        let (Some(c), Some(h), Some(n)) = (it.next(), it.next(), it.next()) else {
91            continue;
92        };
93        let (Ok(c), Ok(h), Ok(n)) = (c.parse::<u32>(), h.parse::<u32>(), n.parse::<u32>()) else {
94            continue;
95        };
96        // The host-root identity map ("0 0 <whole space>") is not a userns.
97        if c == 0 && h == 0 {
98            identity_root = true;
99        }
100        ranges.push((c, n));
101    }
102    if identity_root && ranges.len() == 1 {
103        return None;
104    }
105    if ranges.is_empty() {
106        None
107    } else {
108        Some(ranges)
109    }
110}
111
112/// True when we are inside a non-trivial user namespace (keep-id / auto /
113/// root-remapped), where raw uid comparisons against the effective uid are
114/// unsafe because our own files appear under a non-root container uid.
115pub fn in_nontrivial_userns() -> bool {
116    current_userns_container_ranges().is_some()
117}
118
119/// True if `uid` (a container uid as seen from inside our userns) is mapped —
120/// i.e. it corresponds to a host uid we are authorized to act as. Returns
121/// `false` when we are not in a userns (caller should use its strict check).
122pub fn uid_is_mapped_in_current_userns(uid: u32) -> bool {
123    current_userns_container_ranges()
124        .map(|ranges| {
125            ranges
126                .iter()
127                .any(|(start, count)| uid >= *start && uid < start.saturating_add(*count))
128        })
129        .unwrap_or(false)
130}
131
132/// Resolve the calling user's login name (for `/etc/subuid` name lookup).
133pub fn current_username() -> Option<String> {
134    // Prefer the real uid; fall back to environment if the libc lookup fails.
135    let uid = nix::unistd::getuid().as_raw();
136    if let Some(cname) = nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(uid)).ok().flatten()
137    {
138        return Some(cname.name);
139    }
140    std::env::var("USER").or_else(|_| std::env::var("LOGNAME")).ok()
141}
142
143/// Parse a single `container:host:size` mapping triplet (Podman `--uidmap` /
144/// Docker `--userns-uid-map` syntax). Returns `None` on malformed input.
145fn parse_map_triplet(s: &str) -> Option<IdMapping> {
146    let mut it = s.split(':');
147    let container_id = it.next()?.trim().parse::<u32>().ok()?;
148    let host_id = it.next()?.trim().parse::<u32>().ok()?;
149    let count = it.next()?.trim().parse::<u32>().ok()?;
150    if it.next().is_some() {
151        return None;
152    }
153    Some(IdMapping::new(container_id, host_id, count))
154}
155
156/// UID/GID mapping configuration for user namespaces
157///
158/// Maps a range of UIDs/GIDs inside the container to a range outside
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct IdMapping {
161    /// ID inside the container
162    pub container_id: u32,
163    /// ID outside the container (on the host)
164    pub host_id: u32,
165    /// Number of IDs to map
166    pub count: u32,
167}
168
169impl IdMapping {
170    /// Create a new ID mapping with validation
171    pub fn new(container_id: u32, host_id: u32, count: u32) -> Self {
172        Self {
173            container_id,
174            host_id,
175            count,
176        }
177    }
178
179    /// Validate the mapping for safety.
180    ///
181    /// Rejects zero count, overflow in ID ranges, and excessively large
182    /// mappings that could map the entire host UID/GID space.
183    pub fn validate(&self, allow_host_root: bool) -> crate::error::Result<()> {
184        if self.count == 0 {
185            return Err(NucleusError::ConfigError(
186                "ID mapping count must be non-zero".to_string(),
187            ));
188        }
189
190        // Cap at 65536 to prevent overly broad mappings
191        if self.count > 65_536 {
192            return Err(NucleusError::ConfigError(format!(
193                "ID mapping count {} exceeds maximum 65536",
194                self.count
195            )));
196        }
197
198        // Check for overflow in container_id + count
199        if self.container_id.checked_add(self.count).is_none() {
200            return Err(NucleusError::ConfigError(format!(
201                "ID mapping overflow: container_id {} + count {} exceeds u32",
202                self.container_id, self.count
203            )));
204        }
205
206        // Check for overflow in host_id + count
207        if self.host_id.checked_add(self.count).is_none() {
208            return Err(NucleusError::ConfigError(format!(
209                "ID mapping overflow: host_id {} + count {} exceeds u32",
210                self.host_id, self.count
211            )));
212        }
213
214        // Reject mapping host UID 0 unless explicitly allowed (e.g., root-remapped mode)
215        if !allow_host_root && self.host_id == 0 && self.count > 0 {
216            return Err(NucleusError::ConfigError(
217                "ID mapping includes host UID/GID 0; use root-remapped mode if intentional"
218                    .to_string(),
219            ));
220        }
221
222        Ok(())
223    }
224
225    /// Create a mapping for root inside container to current user outside
226    pub fn rootless() -> Self {
227        let uid = nix::unistd::getuid().as_raw();
228        Self::new(0, uid, 1)
229    }
230
231    /// Format as a line for uid_map/gid_map file
232    fn format(&self) -> String {
233        format!("{} {} {}\n", self.container_id, self.host_id, self.count)
234    }
235}
236
237/// User namespace configuration
238#[derive(Debug, Clone)]
239pub struct UserNamespaceConfig {
240    /// UID mappings
241    pub uid_mappings: Vec<IdMapping>,
242    /// GID mappings
243    pub gid_mappings: Vec<IdMapping>,
244}
245
246impl UserNamespaceConfig {
247    /// Create config for rootless mode
248    ///
249    /// Maps container root (UID/GID 0) to current user. This is the historic
250    /// Nucleus rootless mapping ("nomap"): only container uid 0 is usable, so
251    /// workloads that refuse euid 0 (e.g. PostgreSQL) cannot run. Prefer
252    /// [`UserNamespaceConfig::keep_id`] or [`UserNamespaceConfig::subuid_auto`]
253    /// when `/etc/subuid` is configured.
254    pub fn rootless() -> Self {
255        let uid = nix::unistd::getuid().as_raw();
256        let gid = nix::unistd::getgid().as_raw();
257
258        Self {
259            uid_mappings: vec![IdMapping::new(0, uid, 1)],
260            gid_mappings: vec![IdMapping::new(0, gid, 1)],
261        }
262    }
263
264    /// Rootless "keep-id" mapping (Podman `--userns=keep-id`).
265    ///
266    /// Maps the calling user's own uid/gid to *itself* inside the namespace,
267    /// and maps container root (0) to the start of the delegated subuid/subgid
268    /// range. Because the workload keeps the host uid, bind-mounted host files
269    /// owned by the user are directly accessible with **no ownership shifting**
270    /// — this is the recommended rootless mode for workloads like PostgreSQL
271    /// that (a) refuse euid 0 and (b) read user-owned bind mounts.
272    ///
273    /// Requires `/etc/subuid` and `/etc/subgid` for the calling user.
274    pub fn keep_id() -> Result<Self> {
275        let uid = nix::unistd::getuid().as_raw();
276        let gid = nix::unistd::getgid().as_raw();
277        let (uname, urange, grange) = Self::current_subid_ranges()?;
278
279        let uid_mappings = vec![
280            IdMapping::new(0, urange.start, 1),
281            IdMapping::new(uid, uid, 1),
282        ];
283        let gid_mappings = vec![
284            IdMapping::new(0, grange.start, 1),
285            IdMapping::new(gid, gid, 1),
286        ];
287        info!(
288            "keep-id userns: container root -> host {} (subuid), container {} -> host {} (self)",
289            urange.start, uid, uid
290        );
291        let _ = uname; // resolved only to drive name-based subuid lookup
292        Ok(Self {
293            uid_mappings,
294            gid_mappings,
295        })
296    }
297
298    /// Rootless "auto" mapping (Podman/Docker rootless default).
299    ///
300    /// Maps container root (0) to the calling user, and container 1..N to the
301    /// delegated subuid/subgid range. Workloads therefore run as their image /
302    /// configured uid (e.g. 999 for the Postgres image) mapped into the subuid
303    /// range. Unlike [`Self::keep_id`], bind-mounted host files owned by the
304    /// user are *not* automatically accessible to a non-zero workload uid — the
305    /// caller must align ownership (as with Docker/Podman rootless bind mounts).
306    pub fn subuid_auto() -> Result<Self> {
307        let uid = nix::unistd::getuid().as_raw();
308        let gid = nix::unistd::getgid().as_raw();
309        let (_uname, urange, grange) = Self::current_subid_ranges()?;
310
311        // Keep the delegated range within the IdMapping validation cap (65536)
312        // and within what remains above the caller's own uid to avoid overlap.
313        let ucount = urange.count.min(65_536);
314        let gcount = grange.count.min(65_536);
315        let uid_mappings = vec![IdMapping::new(0, uid, 1), IdMapping::new(1, urange.start, ucount)];
316        let gid_mappings = vec![IdMapping::new(0, gid, 1), IdMapping::new(1, grange.start, gcount)];
317        info!(
318            "auto userns: container 0 -> host {}, container 1..{} -> host {}..{}",
319            uid,
320            ucount,
321            urange.start,
322            urange.start + ucount
323        );
324        Ok(Self {
325            uid_mappings,
326            gid_mappings,
327        })
328    }
329
330    /// Build a mapping from explicit `container:host:size` triplets
331    /// (Podman `--uidmap` / Docker `--userns-uid-map` syntax).
332    pub fn from_map_specs(uid_specs: &[String], gid_specs: &[String]) -> Result<Self> {
333        let parse = |specs: &[String], label: &str| -> Result<Vec<IdMapping>> {
334            specs
335                .iter()
336                .map(|s| {
337                    parse_map_triplet(s).ok_or_else(|| {
338                        NucleusError::ConfigError(format!(
339                            "Invalid {} mapping '{}': expected container:host:size",
340                            label, s
341                        ))
342                    })
343                })
344                .collect()
345        };
346        let uid_mappings = parse(uid_specs, "--uidmap")?;
347        let gid_mappings = parse(gid_specs, "--gidmap")?;
348        if uid_mappings.is_empty() || gid_mappings.is_empty() {
349            return Err(NucleusError::ConfigError(
350                "--uidmap/--gidmap require at least one mapping each".to_string(),
351            ));
352        }
353        Self::custom(uid_mappings, gid_mappings)
354    }
355
356    /// Choose a rootless mapping for the current (unprivileged) process.
357    ///
358    /// When the workload requests a non-zero uid (`--user N`), the historic
359    /// `rootless()` mapping (only container 0) would make that uid unmappable.
360    /// If `/etc/subuid` is configured, transparently pick `keep_id` so the
361    /// requested uid maps to the caller's own uid; otherwise fall back to the
362    /// trivial `rootless()` mapping (the caller will get a clear validation
363    /// error pointing at /etc/subuid).
364    pub fn for_unprivileged_rootless(workload_uid: Option<u32>) -> Self {
365        match workload_uid {
366            Some(uid) if uid != 0 => match Self::keep_id() {
367                Ok(c) => c,
368                Err(e) => {
369                    warn!(
370                        "Could not build keep-id mapping for requested workload uid {}: {}; \
371                         falling back to nomap (container uid {} will be unmappable). Configure \
372                         /etc/subuid + /etc/subgid to enable rootless non-root workloads.",
373                        uid, e, uid
374                    );
375                    Self::rootless()
376                }
377            },
378            _ => Self::rootless(),
379        }
380    }
381
382    /// Resolve the calling user's subuid/subgid ranges, erroring clearly when
383    /// rootless subuid support is required but not configured.
384    fn current_subid_ranges() -> Result<(String, SubidRange, SubidRange)> {
385        let uid = nix::unistd::getuid().as_raw();
386        let uname = current_username().unwrap_or_else(|| format!("uid:{}", uid));
387        let urange = subuid_range_for_user(&uname, uid).ok_or_else(|| {
388            NucleusError::ConfigError(format!(
389                "rootless subuid mode requires an entry for '{}' in /etc/subuid \
390                 (configure with `useradd -v ...` or `podman system migrate`); \
391                 falling back requires --userns=nomap",
392                uname
393            ))
394        })?;
395        let grange = subgid_range_for_user(&uname, uid).ok_or_else(|| {
396            NucleusError::ConfigError(format!(
397                "rootless subuid mode requires an entry for '{}' in /etc/subgid",
398                uname
399            ))
400        })?;
401        Ok((uname, urange, grange))
402    }
403
404    /// Whether the configured mapping can be written to `/proc/<pid>/{u,g}id_map`
405    /// by an **unprivileged** process directly. The kernel only permits this for
406    /// the trivial single `0 <own> 1` mapping; anything else requires the
407    /// setuid `newuidmap`/`newgidmap` helpers (which authorize via /etc/subuid).
408    pub fn needs_setuid_helper(&self) -> bool {
409        let uid = nix::unistd::getuid().as_raw();
410        let gid = nix::unistd::getgid().as_raw();
411        let trivial_uid = self.uid_mappings.len() == 1
412            && self.uid_mappings[0] == IdMapping::new(0, uid, 1);
413        let trivial_gid = self.gid_mappings.len() == 1
414            && self.gid_mappings[0] == IdMapping::new(0, gid, 1);
415        !(trivial_uid && trivial_gid)
416    }
417
418    /// Create config for root-remapped mode
419    ///
420    /// When running as host root, maps container UID 0 to a high unprivileged
421    /// UID range so a container escape does not yield real host root.
422    pub fn root_remapped() -> Self {
423        Self {
424            uid_mappings: vec![IdMapping::new(0, 100_000, 65_536)],
425            gid_mappings: vec![IdMapping::new(0, 100_000, 65_536)],
426        }
427    }
428
429    /// Create config with custom mappings (validated)
430    pub fn custom(
431        uid_mappings: Vec<IdMapping>,
432        gid_mappings: Vec<IdMapping>,
433    ) -> crate::error::Result<Self> {
434        let allow_host_root = nix::unistd::Uid::effective().is_root();
435        for mapping in &uid_mappings {
436            mapping.validate(allow_host_root)?;
437        }
438        for mapping in &gid_mappings {
439            mapping.validate(allow_host_root)?;
440        }
441        Ok(Self {
442            uid_mappings,
443            gid_mappings,
444        })
445    }
446}
447
448/// User namespace mapper
449///
450/// Handles UID/GID mapping for rootless container execution
451pub struct UserNamespaceMapper {
452    config: UserNamespaceConfig,
453}
454
455impl UserNamespaceMapper {
456    pub fn new(config: UserNamespaceConfig) -> Self {
457        Self { config }
458    }
459
460    /// Setup UID/GID mappings for the current process
461    ///
462    /// This must be called after unshare(CLONE_NEWUSER) and before any other
463    /// namespace operations
464    pub fn setup_mappings(&self) -> Result<()> {
465        if !self.can_self_map_current_process() {
466            return Err(NucleusError::NamespaceError(
467                "This user namespace mapping must be written from a process outside the new \
468                 user namespace; use write_mappings_for_pid() from the parent after fork"
469                    .to_string(),
470            ));
471        }
472
473        self.write_mappings_for_pid(std::process::id())
474    }
475
476    /// Write UID/GID mappings for the given process from an external writer.
477    ///
478    /// For privileged multi-ID mappings, Linux requires a task outside the new
479    /// user namespace to write `/proc/<pid>/{uid,gid}_map`. When the caller is
480    /// unprivileged and the mapping is not the trivial single-id self-map, the
481    /// kernel denies a direct write; in that case we fall back to the setuid
482    /// `newuidmap`/`newgidmap` helpers, which authorize via `/etc/subuid`.
483    /// This is exactly the mechanism Docker/Podman rootless use.
484    pub fn write_mappings_for_pid(&self, pid: u32) -> Result<()> {
485        info!("Setting up user namespace mappings for pid {}", pid);
486
487        let is_root = nix::unistd::Uid::effective().is_root();
488        let use_helper = !is_root && self.config.needs_setuid_helper();
489
490        if use_helper {
491            // setgroups must be denied before writing gid_map only for the
492            // trivial unprivileged single-map case. With a helper (privileged
493            // writer) we leave setgroups enabled, matching newgidmap semantics.
494            self.write_mappings_via_setuid_helper(pid)?;
495        } else {
496            if self.should_deny_setgroups() {
497                self.write_setgroups_deny(pid)?;
498            }
499            self.write_uid_map(pid)?;
500            self.write_gid_map(pid)?;
501        }
502
503        info!(
504            "Successfully configured user namespace mappings for pid {}",
505            pid
506        );
507        Ok(())
508    }
509
510    /// Invoke the setuid `newuidmap`/`newgidmap` helpers to write the maps.
511    ///
512    /// These binaries (from shadow-utils) are setuid-root and read `/etc/subuid`
513    /// and `/etc/subgid` to authorize the requested ranges. On NixOS they live
514    /// in `/run/wrappers/bin`; elsewhere in `/usr/bin` or `/usr/sbin`.
515    fn write_mappings_via_setuid_helper(&self, pid: u32) -> Result<()> {
516        let pid_str = pid.to_string();
517
518        // Flatten each IdMapping (container:host:size) into separate argv tokens.
519        let mut uid_args: Vec<String> = Vec::new();
520        for m in &self.config.uid_mappings {
521            uid_args.push(m.container_id.to_string());
522            uid_args.push(m.host_id.to_string());
523            uid_args.push(m.count.to_string());
524        }
525        let mut gid_args: Vec<String> = Vec::new();
526        for m in &self.config.gid_mappings {
527            gid_args.push(m.container_id.to_string());
528            gid_args.push(m.host_id.to_string());
529            gid_args.push(m.count.to_string());
530        }
531
532        Self::run_idmap_helper("newgidmap", &pid_str, &gid_args)?;
533        Self::run_idmap_helper("newuidmap", &pid_str, &uid_args)?;
534        Ok(())
535    }
536
537    fn run_idmap_helper(prog: &str, pid_str: &str, map_args: &[String]) -> Result<()> {
538        let path = Self::find_setuid_helper(prog)?;
539        debug!(
540            "Invoking idmap helper {:?} for pid {} with args {:?}",
541            path, pid_str, map_args
542        );
543        let status = std::process::Command::new(&path)
544            .arg(pid_str)
545            .args(map_args)
546            .stdin(std::process::Stdio::null())
547            .stdout(std::process::Stdio::null())
548            .stderr(std::process::Stdio::piped())
549            .output()
550            .map_err(|e| {
551                NucleusError::NamespaceError(format!("Failed to execute {}: {}", path.display(), e))
552            })?;
553        if !status.status.success() {
554            let stderr = String::from_utf8_lossy(&status.stderr);
555            return Err(NucleusError::NamespaceError(format!(
556                "{} failed (exit {:?}): {}",
557                prog,
558                status.status.code(),
559                stderr.trim()
560            )));
561        }
562        Ok(())
563    }
564
565    /// Locate a setuid idmap helper. Search the standard wrapper/bin paths.
566    fn find_setuid_helper(prog: &str) -> Result<PathBuf> {
567        // NixOS exposes these as setuid wrappers under /run/wrappers/bin.
568        const CANDIDATE_DIRS: &[&str] = &[
569            "/run/wrappers/bin",
570            "/usr/bin",
571            "/usr/sbin",
572            "/bin",
573            "/usr/local/bin",
574        ];
575        for dir in CANDIDATE_DIRS {
576            let p = Path::new(dir).join(prog);
577            if p.is_file() {
578                return Ok(p);
579            }
580        }
581        // Last resort: trust PATH (shells may put newuidmap elsewhere).
582        if let Ok(out) = std::process::Command::new("which").arg(prog).output() {
583            if out.status.success() {
584                let s = String::from_utf8_lossy(&out.stdout);
585                let trimmed = s.trim();
586                if !trimmed.is_empty() {
587                    return Ok(PathBuf::from(trimmed));
588                }
589            }
590        }
591        warn!(
592            "Could not find {}; install shadow-utils (or its setuid wrappers) for rootless subuid mode",
593            prog
594        );
595        Err(NucleusError::NamespaceError(format!(
596            "Required setuid helper '{}' not found in PATH or standard wrapper directories",
597            prog
598        )))
599    }
600
601    fn can_self_map_current_process(&self) -> bool {
602        let uid = nix::unistd::getuid().as_raw();
603        let gid = nix::unistd::getgid().as_raw();
604
605        self.config.uid_mappings.len() == 1
606            && self.config.gid_mappings.len() == 1
607            && self.config.uid_mappings[0] == IdMapping::new(0, uid, 1)
608            && self.config.gid_mappings[0] == IdMapping::new(0, gid, 1)
609    }
610
611    fn should_deny_setgroups(&self) -> bool {
612        self.config.gid_mappings.len() == 1 && self.config.gid_mappings[0].count == 1
613    }
614
615    /// Write to /proc/<pid>/setgroups to deny setgroups(2)
616    ///
617    /// This is required for the unprivileged single-ID gid_map case.
618    fn write_setgroups_deny(&self, pid: u32) -> Result<()> {
619        let path = format!("/proc/{}/setgroups", pid);
620        debug!("Writing 'deny' to {}", path);
621
622        fs::write(&path, "deny\n").map_err(|e| {
623            NucleusError::NamespaceError(format!("Failed to write to {}: {}", path, e))
624        })?;
625
626        Ok(())
627    }
628
629    /// Write UID mappings to /proc/<pid>/uid_map
630    fn write_uid_map(&self, pid: u32) -> Result<()> {
631        let path = format!("/proc/{}/uid_map", pid);
632        let mut content = String::new();
633
634        for mapping in &self.config.uid_mappings {
635            content.push_str(&mapping.format());
636        }
637
638        debug!("Writing UID mappings to {}: {}", path, content.trim());
639
640        fs::write(&path, &content).map_err(|e| {
641            NucleusError::NamespaceError(format!("Failed to write UID mappings: {}", e))
642        })?;
643
644        Ok(())
645    }
646
647    /// Write GID mappings to /proc/<pid>/gid_map
648    fn write_gid_map(&self, pid: u32) -> Result<()> {
649        let path = format!("/proc/{}/gid_map", pid);
650        let mut content = String::new();
651
652        for mapping in &self.config.gid_mappings {
653            content.push_str(&mapping.format());
654        }
655
656        debug!("Writing GID mappings to {}: {}", path, content.trim());
657
658        fs::write(&path, &content).map_err(|e| {
659            NucleusError::NamespaceError(format!("Failed to write GID mappings: {}", e))
660        })?;
661
662        Ok(())
663    }
664
665    /// Get the user namespace configuration
666    pub fn config(&self) -> &UserNamespaceConfig {
667        &self.config
668    }
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    #[test]
676    fn test_id_mapping_format() {
677        let mapping = IdMapping::new(0, 1000, 1);
678        assert_eq!(mapping.format(), "0 1000 1\n");
679
680        let mapping = IdMapping::new(1000, 2000, 100);
681        assert_eq!(mapping.format(), "1000 2000 100\n");
682    }
683
684    #[test]
685    fn test_id_mapping_rootless() {
686        let mapping = IdMapping::rootless();
687        assert_eq!(mapping.container_id, 0);
688        assert_eq!(mapping.count, 1);
689        // host_id will be the current UID
690    }
691
692    #[test]
693    fn test_user_namespace_config_rootless() {
694        let config = UserNamespaceConfig::rootless();
695        assert_eq!(config.uid_mappings.len(), 1);
696        assert_eq!(config.gid_mappings.len(), 1);
697        assert_eq!(config.uid_mappings[0].container_id, 0);
698        assert_eq!(config.gid_mappings[0].container_id, 0);
699    }
700
701    #[test]
702    fn test_user_namespace_config_custom() {
703        let uid_mappings = vec![IdMapping::new(0, 1000, 1), IdMapping::new(1000, 2000, 100)];
704        let gid_mappings = vec![IdMapping::new(0, 1000, 1)];
705
706        let config =
707            UserNamespaceConfig::custom(uid_mappings.clone(), gid_mappings.clone()).unwrap();
708        assert_eq!(config.uid_mappings, uid_mappings);
709        assert_eq!(config.gid_mappings, gid_mappings);
710    }
711
712    #[test]
713    fn test_rootless_mapping_can_self_map_current_process() {
714        let mapper = UserNamespaceMapper::new(UserNamespaceConfig::rootless());
715        assert!(mapper.can_self_map_current_process());
716        assert!(mapper.should_deny_setgroups());
717    }
718
719    #[test]
720    fn test_root_remapped_requires_external_writer() {
721        let mapper = UserNamespaceMapper::new(UserNamespaceConfig::root_remapped());
722        assert!(!mapper.can_self_map_current_process());
723        assert!(!mapper.should_deny_setgroups());
724        assert!(mapper.setup_mappings().is_err());
725    }
726
727    #[test]
728    fn test_parse_map_triplet_podman_syntax() {
729        assert_eq!(
730            parse_map_triplet("0:1000:1"),
731            Some(IdMapping::new(0, 1000, 1))
732        );
733        assert_eq!(
734            parse_map_triplet(" 1 : 100000 : 65536 "),
735            Some(IdMapping::new(1, 100_000, 65_536))
736        );
737        // Malformed inputs are rejected, not panicking.
738        assert_eq!(parse_map_triplet("0:1000"), None);        // missing size
739        assert_eq!(parse_map_triplet("0:1000:1:9"), None);    // too many fields
740        assert_eq!(parse_map_triplet("x:1000:1"), None);      // non-numeric
741    }
742
743    #[test]
744    fn test_subid_range_for_parses_name_and_uid_entries() {
745        let dir = std::env::temp_dir();
746        let path = dir.join(format!("nucleus-subuid-test-{}", std::process::id()));
747        std::fs::write(
748            &path,
749            "root:100000:65536\nalice:200000:65536\n1000:300000:65536\n",
750        )
751        .unwrap();
752        let p = path.to_str().unwrap();
753        // Name lookup wins over uid.
754        assert_eq!(
755            subid_range_for(p, "alice", 1000),
756            Some(SubidRange { start: 200_000, count: 65_536 })
757        );
758        // Numeric uid fallback when no name match.
759        assert_eq!(
760            subid_range_for(p, "nobody", 1000),
761            Some(SubidRange { start: 300_000, count: 65_536 })
762        );
763        // No match at all.
764        assert_eq!(subid_range_for(p, "ghost", 4242), None);
765        let _ = std::fs::remove_file(&path);
766    }
767
768    #[test]
769    fn test_from_map_specs_validates() {
770        let cfg = UserNamespaceConfig::from_map_specs(
771            &["0:1000:1".to_string(), "1:100000:65536".to_string()],
772            &["0:100:1".to_string()],
773        )
774        .unwrap();
775        assert_eq!(cfg.uid_mappings.len(), 2);
776        assert_eq!(cfg.uid_mappings[1], IdMapping::new(1, 100_000, 65_536));
777        // Missing gid specs is an error.
778        assert!(UserNamespaceConfig::from_map_specs(
779            &["0:1000:1".to_string()],
780            &[],
781        )
782        .is_err());
783        // Malformed spec is an error.
784        assert!(UserNamespaceConfig::from_map_specs(
785            &["bogus".to_string()],
786            &["0:100:1".to_string()],
787        )
788        .is_err());
789    }
790
791    #[test]
792    fn test_needs_setuid_helper_classification() {
793        // The trivial rootless self-map can be written directly (no helper).
794        assert!(!UserNamespaceConfig::rootless().needs_setuid_helper());
795        // Any multi-entry mapping (keep-id, auto, root-remapped) needs the
796        // setuid helper when the caller is unprivileged.
797        assert!(UserNamespaceConfig::root_remapped().needs_setuid_helper());
798    }
799
800    #[test]
801    fn test_keep_id_mapping_is_disjoint() {
802        // keep-id requires /etc/subuid; build it manually here to assert shape.
803        // Two entries, container ranges 0 and <own_uid> must not overlap, and
804        // host ranges (subuid_start vs own_uid) must not overlap either.
805        let uid = nix::unistd::getuid().as_raw();
806        let gid = nix::unistd::getgid().as_raw();
807        let cfg = UserNamespaceConfig::custom(
808            vec![IdMapping::new(0, 100_000, 1), IdMapping::new(uid, uid, 1)],
809            vec![IdMapping::new(0, 100_000, 1), IdMapping::new(gid, gid, 1)],
810        )
811        .unwrap();
812        assert!(cfg.needs_setuid_helper());
813        assert_eq!(cfg.uid_mappings.len(), 2);
814        // own_uid must map to itself (the keep-id invariant).
815        assert_eq!(cfg.uid_mappings[1].host_id, uid);
816        assert_eq!(cfg.uid_mappings[1].container_id, uid);
817    }
818
819    // Note: Testing actual mapping setup requires user namespace creation
820    // This is tested in integration tests
821}