Skip to main content

isb_core/
idmap.rs

1//! Deciding whether a sandbox needs `raw.idmap`.
2//!
3//! The requirement is only ever that a host uid/gid lands on a guest uid/gid so a
4//! bind mount is writable. Three hosts, three answers:
5//!
6//! - A host whose `/etc/subuid` gives root a range that does NOT contain the uid
7//!   (plus a `root:1000:1` delegation): the default map puts the container
8//!   elsewhere, and `raw.idmap` is what pulls the id through.
9//! - A nested box where root's range starts at 0: the default map is already the
10//!   identity, and asking for `raw.idmap` is refused ("Host ID is in the range of
11//!   subids").
12//! - macOS and its `isb machine` VM: bind sources are the Mac home over Apple's
13//!   virtiofs, which reports every file as owned by whoever asks and writes as
14//!   the Mac user, so every guest uid can already write them.
15//!
16//! Only a real RANGE (count > 1) counts. A `root:1000:1` line is the delegation
17//! that permits `raw.idmap` to map 1000 at all, not a range the default map draws
18//! from; treating it as one answers "not needed" on exactly the host that needs it.
19
20use crate::error::{Error, Result};
21use crate::spec::{IdmapMode, IdmapSpec, MountType, SandboxSpec};
22
23/// Whether `id` falls inside a subordinate id RANGE (count > 1) owned by `owner`
24/// (`root` or `0`) in subuid/subgid file content.
25pub fn in_subid_range(content: &str, owner: &str, id: u32) -> bool {
26    content.lines().any(|line| {
27        let mut parts = line.trim().split(':');
28        let (Some(who), Some(start), Some(count)) = (parts.next(), parts.next(), parts.next())
29        else {
30            return false;
31        };
32        if who != owner && !(owner == "root" && who == "0") {
33            return false;
34        }
35        let (Ok(start), Ok(count)) = (start.trim().parse::<u64>(), count.trim().parse::<u64>())
36        else {
37            return false;
38        };
39        count > 1 && (id as u64) >= start && (id as u64) < start + count
40    })
41}
42
43/// Host facts that decide the idmap. Read from `/etc/subuid` and `/etc/subgid`
44/// (empty where those do not exist).
45#[derive(Debug, Clone, Default)]
46pub struct SubIds {
47    pub subuid: String,
48    pub subgid: String,
49    /// Bind sources live on a filesystem that reports every file as owned by
50    /// whoever asks and writes as one fixed user: the `isb machine`'s macOS
51    /// home over virtiofs. Any guest user can then read and write them, so
52    /// `auto` maps nothing.
53    pub caller_owned: bool,
54}
55
56/// Set in the `isb machine` VM, whose bind sources are the shared Mac home.
57pub const CALLER_OWNED_ENV: &str = "ISB_BIND_CALLER_OWNED";
58
59impl SubIds {
60    pub fn read_host() -> Self {
61        SubIds {
62            subuid: std::fs::read_to_string("/etc/subuid").unwrap_or_default(),
63            subgid: std::fs::read_to_string("/etc/subgid").unwrap_or_default(),
64            caller_owned: cfg!(target_os = "macos")
65                || std::env::var(CALLER_OWNED_ENV).is_ok_and(|v| v == "1"),
66        }
67    }
68}
69
70/// The `raw.idmap` value for a spec on this host, or `None` if it should not be set.
71pub fn resolve(spec: &IdmapSpec, host: &SubIds) -> Option<String> {
72    let (mode, hu, hg, gu, gg) = match spec {
73        IdmapSpec::Raw(r) => return Some(r.raw.clone()),
74        IdmapSpec::Mode(m) => (*m, 1000, 1000, 1000, 1000),
75        IdmapSpec::Map(m) => (m.mode, m.host_uid, m.host_gid, m.guest_uid, m.guest_gid),
76    };
77    let (need_uid, need_gid) = match mode {
78        IdmapMode::None => return None,
79        IdmapMode::Always => (true, true),
80        IdmapMode::Auto if host.caller_owned => return None,
81        IdmapMode::Auto => (
82            !in_subid_range(&host.subuid, "root", hu),
83            !in_subid_range(&host.subgid, "root", hg),
84        ),
85    };
86    render(need_uid.then_some((hu, gu)), need_gid.then_some((hg, gg)))
87}
88
89/// The oldest incus verified to hand a VM's `raw.idmap` to virtiofsd as
90/// `--translate-uid`/`--translate-gid` (7.5.1). An older or unreadable version
91/// may ignore the key and share the host directory untranslated, so isb refuses
92/// rather than guess.
93pub const VM_IDMAP_MIN_INCUS: (u32, u32) = (7, 5);
94
95/// Whether `server_version` (`environment.server_version`, e.g. `7.5.1`) is at
96/// least [`VM_IDMAP_MIN_INCUS`].
97pub fn incus_translates_vm_shares(server_version: Option<&str>) -> bool {
98    let Some(v) = server_version else {
99        return false;
100    };
101    let mut parts = v
102        .split(|c: char| !c.is_ascii_digit())
103        .filter(|p| !p.is_empty());
104    let (Some(major), Some(minor)) = (
105        parts.next().and_then(|p| p.parse::<u32>().ok()),
106        parts.next().and_then(|p| p.parse::<u32>().ok()),
107    ) else {
108        return false;
109    };
110    (major, minor) >= VM_IDMAP_MIN_INCUS
111}
112
113/// The (uid, gid) of the user running isb.
114pub fn invoking_ids() -> (u32, u32) {
115    (
116        rustix::process::getuid().as_raw(),
117        rustix::process::getgid().as_raw(),
118    )
119}
120
121/// What a container spec decides about `raw.idmap`: the mode (for the plan's
122/// "unset by hand" note, none for `{raw: ...}`) and the value to set.
123pub fn plan_container(
124    spec: Option<&IdmapSpec>,
125    host: &SubIds,
126) -> (Option<IdmapMode>, Option<String>) {
127    let Some(i) = spec else {
128        return (None, None);
129    };
130    let mode = match i {
131        IdmapSpec::Mode(m) => Some(*m),
132        IdmapSpec::Map(m) => Some(m.mode),
133        IdmapSpec::Raw(_) => None,
134    };
135    (mode, resolve(i, host))
136}
137
138/// What a VM spec decides about `raw.idmap`: the mode that applies (for the
139/// plan's "unset by hand" note) and the value to set. A VM with no host bind
140/// mount needs neither. An incus that may not translate VM shares is an error
141/// unless the spec opts out with `idmap: none`, which warns.
142pub fn plan_vm(
143    name: &str,
144    spec: &SandboxSpec,
145    incus_version: Option<&str>,
146    invoking: (u32, u32),
147) -> Result<(Option<IdmapMode>, Option<String>)> {
148    if !spec.volumes.iter().any(|v| v.mount_type == MountType::Bind) {
149        return Ok((None, None));
150    }
151    let mode = match &spec.idmap {
152        Some(IdmapSpec::Mode(m)) => Some(*m),
153        Some(IdmapSpec::Map(m)) => Some(m.mode),
154        Some(IdmapSpec::Raw(_)) => None,
155        None => Some(IdmapMode::Auto),
156    };
157    let guest = vm_service_ids(spec.user.as_deref());
158    let Some(value) = resolve_vm(spec.idmap.as_ref(), invoking, guest) else {
159        eprintln!(
160            "isb: warning: {name}: idmap: none shares host bind mounts into a VM untranslated: \
161             guest root creates root-owned files, and setuid binaries, on the host"
162        );
163        return Ok((mode, None));
164    };
165    if !incus_translates_vm_shares(incus_version) {
166        return Err(Error::invalid(format!(
167            "{name}: this VM bind-mounts host directories, and incus {} is not known to \
168             translate their ids (needs {}.{} or later): guest root would own files on \
169             the host and could plant setuid binaries. Upgrade incus, or set \
170             `idmap: none` to share them untranslated (unsafe for untrusted code)",
171            incus_version.unwrap_or("(unknown version)"),
172            VM_IDMAP_MIN_INCUS.0,
173            VM_IDMAP_MIN_INCUS.1,
174        )));
175    }
176    Ok((mode, Some(value)))
177}
178
179/// The guest (uid, gid) a VM's host shares map to by default: the service
180/// user's numeric `user:` (`1000` or `1000:1000`), root when it is unset or
181/// `root`, and 1000 for a named user (the `dev` user of dev-base).
182pub fn vm_service_ids(user: Option<&str>) -> (u32, u32) {
183    let Some(user) = user.map(str::trim).filter(|u| !u.is_empty()) else {
184        return (0, 0);
185    };
186    let (u, g) = user.split_once(':').unwrap_or((user, ""));
187    let uid = match u {
188        "root" => 0,
189        n => n.parse().unwrap_or(1000),
190    };
191    let gid = match g {
192        "" => uid,
193        "root" => 0,
194        n => n.parse().unwrap_or(uid),
195    };
196    (uid, gid)
197}
198
199/// The `raw.idmap` value for a VM that bind-mounts host directories, or `None`
200/// when the spec opts out (`idmap: none`).
201///
202/// A VM's host shares go over virtiofs, and virtiofsd translates ids itself:
203/// the one guest id in the map reads and writes as the host id, and every other
204/// guest id (root included, unless it is the mapped one) is refused with an
205/// error when it creates a file, chowns one or makes a device node. The map is
206/// strictly one guest id per host id, so root and the service user cannot both
207/// be mapped; the default is `guest` (see [`vm_service_ids`]). `host_*` defaults
208/// to `invoking`, the user running isb, not 1000.
209pub fn resolve_vm(
210    spec: Option<&IdmapSpec>,
211    invoking: (u32, u32),
212    guest: (u32, u32),
213) -> Option<String> {
214    let (hu, hg, gu, gg) = match spec {
215        Some(IdmapSpec::Raw(r)) => return Some(r.raw.clone()),
216        Some(IdmapSpec::Mode(IdmapMode::None)) => return None,
217        Some(IdmapSpec::Map(m)) if m.mode == IdmapMode::None => return None,
218        Some(IdmapSpec::Map(m)) => (m.host_uid, m.host_gid, m.guest_uid, m.guest_gid),
219        Some(IdmapSpec::Mode(_)) | None => (invoking.0, invoking.1, guest.0, guest.1),
220    };
221    render(Some((hu, gu)), Some((hg, gg)))
222}
223
224fn render(uid: Option<(u32, u32)>, gid: Option<(u32, u32)>) -> Option<String> {
225    match (uid, gid) {
226        (None, None) => None,
227        (Some(u), Some(g)) if u == g => Some(format!("both {} {}", u.0, u.1)),
228        (u, g) => {
229            let mut lines = Vec::new();
230            if let Some((h, c)) = u {
231                lines.push(format!("uid {h} {c}"));
232            }
233            if let Some((h, c)) = g {
234                lines.push(format!("gid {h} {c}"));
235            }
236            Some(lines.join("\n"))
237        }
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::spec::{IdmapMap, IdmapRaw};
245
246    // titan: root's range starts at 1000000, plus the root:1000:1 delegation.
247    const TITAN: &str = "stephan:100000:65536\nroot:1000000:1000000000\nroot:1000:1\n";
248    // A nested workspace box: root's range starts at 0 (identity map).
249    const BOX: &str = "root:0:1000000000\n";
250
251    fn host(s: &str) -> SubIds {
252        SubIds {
253            subuid: s.into(),
254            subgid: s.into(),
255            caller_owned: false,
256        }
257    }
258
259    // The isb machine: the shared home answers every caller as its owner.
260    #[test]
261    fn auto_maps_nothing_on_caller_owned_binds() {
262        let mut h = host(TITAN);
263        h.caller_owned = true;
264        assert_eq!(resolve(&IdmapSpec::Mode(IdmapMode::Auto), &h), None);
265        assert_eq!(
266            resolve(&IdmapSpec::Mode(IdmapMode::Always), &h).as_deref(),
267            Some("both 1000 1000")
268        );
269    }
270
271    #[test]
272    fn vm_default_maps_the_service_user_to_the_invoker() {
273        assert_eq!(
274            resolve_vm(None, (1001, 1002), (1000, 1000)).as_deref(),
275            Some("uid 1001 1000\ngid 1002 1000")
276        );
277        assert_eq!(
278            resolve_vm(
279                Some(&IdmapSpec::Mode(IdmapMode::Auto)),
280                (1000, 1000),
281                (1000, 1000)
282            )
283            .as_deref(),
284            Some("both 1000 1000")
285        );
286        assert_eq!(
287            resolve_vm(
288                Some(&IdmapSpec::Mode(IdmapMode::None)),
289                (1000, 1000),
290                (0, 0)
291            ),
292            None
293        );
294        let root = IdmapSpec::Map(IdmapMap {
295            mode: IdmapMode::Always,
296            host_uid: 1000,
297            host_gid: 1000,
298            guest_uid: 0,
299            guest_gid: 0,
300        });
301        assert_eq!(
302            resolve_vm(Some(&root), (5, 5), (0, 0)).as_deref(),
303            Some("both 1000 0")
304        );
305    }
306
307    #[test]
308    fn vm_guest_id_follows_the_service_user() {
309        assert_eq!(vm_service_ids(None), (0, 0));
310        assert_eq!(vm_service_ids(Some("root")), (0, 0));
311        assert_eq!(vm_service_ids(Some("dev")), (1000, 1000));
312        assert_eq!(vm_service_ids(Some("1001")), (1001, 1001));
313        assert_eq!(vm_service_ids(Some("1001:50")), (1001, 50));
314    }
315
316    #[test]
317    fn vm_translation_needs_incus_7_5() {
318        assert!(incus_translates_vm_shares(Some("7.5.1")));
319        assert!(incus_translates_vm_shares(Some("7.10.0")));
320        assert!(incus_translates_vm_shares(Some("8.0")));
321        assert!(!incus_translates_vm_shares(Some("7.4.9")));
322        assert!(!incus_translates_vm_shares(Some("6.0.4")));
323        assert!(!incus_translates_vm_shares(Some("garbage")));
324        assert!(!incus_translates_vm_shares(None));
325    }
326
327    #[test]
328    fn delegation_line_is_not_a_range() {
329        assert!(!in_subid_range(TITAN, "root", 1000));
330        assert!(in_subid_range(BOX, "root", 1000));
331        assert!(in_subid_range("0:0:65536\n", "root", 1000));
332        assert!(!in_subid_range("", "root", 1000));
333        assert!(!in_subid_range("garbage\nroot:x:y\n", "root", 1000));
334        // Range end is exclusive.
335        assert!(!in_subid_range("root:0:1000\n", "root", 1000));
336    }
337
338    #[test]
339    fn auto_on_titan_maps_both() {
340        let s = IdmapSpec::Mode(IdmapMode::Auto);
341        assert_eq!(resolve(&s, &host(TITAN)).as_deref(), Some("both 1000 1000"));
342    }
343
344    #[test]
345    fn auto_in_box_sets_nothing() {
346        let s = IdmapSpec::Mode(IdmapMode::Auto);
347        assert_eq!(resolve(&s, &host(BOX)), None);
348    }
349
350    #[test]
351    fn auto_per_id() {
352        let s = IdmapSpec::Mode(IdmapMode::Auto);
353        let h = SubIds {
354            subuid: BOX.into(),
355            subgid: TITAN.into(),
356            caller_owned: false,
357        };
358        assert_eq!(resolve(&s, &h).as_deref(), Some("gid 1000 1000"));
359    }
360
361    #[test]
362    fn explicit_modes() {
363        assert_eq!(
364            resolve(&IdmapSpec::Mode(IdmapMode::None), &host(TITAN)),
365            None
366        );
367        assert_eq!(
368            resolve(&IdmapSpec::Mode(IdmapMode::Always), &host(BOX)).as_deref(),
369            Some("both 1000 1000")
370        );
371        let raw = IdmapSpec::Raw(IdmapRaw {
372            raw: "uid 5 6".into(),
373        });
374        assert_eq!(resolve(&raw, &host(BOX)).as_deref(), Some("uid 5 6"));
375        let m = IdmapSpec::Map(IdmapMap {
376            mode: IdmapMode::Always,
377            host_uid: 1001,
378            host_gid: 1002,
379            guest_uid: 1000,
380            guest_gid: 1000,
381        });
382        assert_eq!(
383            resolve(&m, &host(BOX)).as_deref(),
384            Some("uid 1001 1000\ngid 1002 1000")
385        );
386    }
387}