Skip to main content

isb_core/
owner.rs

1//! A named volume's mount point: its owner and mode, set from the host.
2//!
3//! Users and groups resolve against the image's own `/etc/passwd` and
4//! `/etc/group` (read through the file API); the stat, chown and chmod go
5//! through SFTP in the instance's namespace. Nothing runs in the guest, so an
6//! image with no shell (distroless, scratch) works the same as any other.
7
8use std::time::Duration;
9
10use crate::client::Client;
11use crate::error::{Error, Result};
12use crate::sftp::Sftp;
13
14/// The mode incus gives the root of a new, unseeded volume.
15pub const FRESH_MODE: u32 = 0o711;
16
17/// An octal mode such as `0770` (or `770`, `0o770`).
18pub fn parse_mode(m: &str) -> std::result::Result<u32, String> {
19    u32::from_str_radix(m.trim().trim_start_matches("0o"), 8)
20        .ok()
21        .filter(|v| *v <= 0o7777)
22        .ok_or_else(|| format!("mode {m:?} is not an octal mode like 0770"))
23}
24
25/// `UID` or `UID:GID`, both numeric: ids known without the image.
26pub fn numeric_ids(owner: &str) -> Option<(u32, u32)> {
27    let (u, g) = owner.split_once(':').unwrap_or((owner, owner));
28    Some((u.parse().ok()?, g.parse().ok()?))
29}
30
31/// What to do to one mount point.
32#[derive(Debug, Clone, Default, PartialEq)]
33pub struct Fix<'a> {
34    pub path: &'a str,
35    /// `USER`, `USER:GROUP` or numeric ids.
36    pub owner: Option<&'a str>,
37    pub mode: Option<u32>,
38    /// Only if the mount point is still what incus makes of a new volume
39    /// (root's, mode 0711 or `mode`): the image did not seed it.
40    pub fresh_only: bool,
41}
42
43/// Apply `fix` in instance `name`. Returns false when `fresh_only` left the
44/// mount point alone.
45pub fn apply(client: &Client, name: &str, fix: &Fix) -> Result<bool> {
46    let step = |what: &str| format!("{what} {} in {name}", fix.path);
47    let resolved = match fix.owner {
48        Some(o) => Some(
49            resolve(client, name, o).map_err(|e| Error::OperationFailed {
50                step: step(&format!("chown {o}")),
51                message: e,
52            })?,
53        ),
54        None => None,
55    };
56    let mut sftp = Sftp::open(client, name, Duration::from_secs(30))?;
57    let st = sftp.stat(fix.path)?.ok_or_else(|| Error::OperationFailed {
58        step: step("stat"),
59        message: "the mount point does not exist".into(),
60    })?;
61    if fix.fresh_only && (st.uid != 0 || (st.mode != FRESH_MODE && Some(st.mode) != fix.mode)) {
62        return Ok(false);
63    }
64    if let Some(r) = &resolved {
65        sftp.chown(fix.path, r.uid, r.gid)?;
66        // Parents the mount conjured are root-owned; fix those inside the
67        // user's home only, and stop at the first one that is not root's.
68        if let Some(home) = r.home.as_deref().filter(|h| !h.is_empty() && *h != "/") {
69            if fix
70                .path
71                .starts_with(&format!("{}/", home.trim_end_matches('/')))
72            {
73                let mut d = parent(fix.path);
74                while d != home.trim_end_matches('/') && d != "/" {
75                    match sftp.stat(&d)? {
76                        Some(s) if s.uid == 0 => sftp.chown(&d, r.uid, r.gid)?,
77                        _ => break,
78                    }
79                    d = parent(&d);
80                }
81            }
82        }
83    }
84    if let Some(m) = fix.mode {
85        sftp.chmod(fix.path, m)?;
86    }
87    Ok(true)
88}
89
90fn parent(p: &str) -> String {
91    match p.trim_end_matches('/').rsplit_once('/') {
92        Some(("", _)) | None => "/".into(),
93        Some((d, _)) => d.into(),
94    }
95}
96
97#[derive(Debug, PartialEq)]
98struct Ids {
99    uid: u32,
100    gid: u32,
101    home: Option<String>,
102}
103
104fn resolve(client: &Client, name: &str, owner: &str) -> std::result::Result<Ids, String> {
105    let read = |path: &str| -> std::result::Result<String, String> {
106        match client.read_file(name, path) {
107            Ok(b) => Ok(String::from_utf8_lossy(&b.unwrap_or_default()).into_owned()),
108            Err(e) => Err(format!("read {path}: {e}")),
109        }
110    };
111    let (_, group) = split(owner);
112    // Read even for numeric ids: the entry's home bounds the parents fixed.
113    let passwd = read("/etc/passwd")?;
114    let groups = match group {
115        Some(g) if g.parse::<u32>().is_err() => read("/etc/group")?,
116        _ => String::new(),
117    };
118    resolve_in(owner, &passwd, &groups)
119}
120
121fn split(owner: &str) -> (&str, Option<&str>) {
122    match owner.split_once(':') {
123        Some((u, g)) => (u, Some(g)),
124        None => (owner, None),
125    }
126}
127
128/// `getent`'s answer from the files' text: a user by name, or a number by
129/// uid; a number with no entry is that uid with the same gid and no home.
130fn resolve_in(owner: &str, passwd: &str, group: &str) -> std::result::Result<Ids, String> {
131    let (user, grp) = split(owner);
132    let entry = passwd.lines().find_map(|l| {
133        let f: Vec<&str> = l.split(':').collect();
134        (f.len() >= 6 && (f[0] == user || (user.parse::<u32>().is_ok() && f[2] == user)))
135            .then_some(f)
136    });
137    let mut ids = match entry {
138        Some(f) => Ids {
139            uid: f[2]
140                .parse()
141                .map_err(|_| format!("bad uid for {user} in /etc/passwd"))?,
142            gid: f[3]
143                .parse()
144                .map_err(|_| format!("bad gid for {user} in /etc/passwd"))?,
145            home: Some(f[5].to_string()),
146        },
147        None => match user.parse() {
148            Ok(n) => Ids {
149                uid: n,
150                gid: n,
151                home: None,
152            },
153            Err(_) => return Err(format!("no such user in the image's /etc/passwd: {user}")),
154        },
155    };
156    match grp {
157        None | Some("") => {}
158        Some(g) => {
159            ids.gid = match g.parse() {
160                Ok(n) => n,
161                Err(_) => group
162                    .lines()
163                    .map(|l| l.split(':').collect::<Vec<_>>())
164                    .find(|f| f.len() >= 3 && f[0] == g)
165                    .and_then(|f| f[2].parse().ok())
166                    .ok_or_else(|| format!("no such group in the image's /etc/group: {g}"))?,
167            }
168        }
169    }
170    Ok(ids)
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    const PASSWD: &str = "root:x:0:0:root:/root:/bin/sh\ndev:x:1000:1000::/home/dev:/bin/bash\napp:x:999:998::/:/sbin/nologin\n";
178    const GROUP: &str = "root:x:0:\nstaff:x:50:dev\n";
179
180    fn ids(uid: u32, gid: u32, home: Option<&str>) -> Ids {
181        Ids {
182            uid,
183            gid,
184            home: home.map(String::from),
185        }
186    }
187
188    #[test]
189    fn owners_resolve_as_getent_and_chown_do() {
190        assert_eq!(
191            resolve_in("dev", PASSWD, GROUP),
192            Ok(ids(1000, 1000, Some("/home/dev")))
193        );
194        assert_eq!(
195            resolve_in("1000", PASSWD, GROUP),
196            Ok(ids(1000, 1000, Some("/home/dev")))
197        );
198        assert_eq!(
199            resolve_in("dev:staff", PASSWD, GROUP),
200            Ok(ids(1000, 50, Some("/home/dev")))
201        );
202        assert_eq!(resolve_in("app:7", PASSWD, ""), Ok(ids(999, 7, Some("/"))));
203        // No passwd entry (or no /etc/passwd at all): the number, twice.
204        assert_eq!(resolve_in("4242", PASSWD, ""), Ok(ids(4242, 4242, None)));
205        assert_eq!(resolve_in("4242:7", "", ""), Ok(ids(4242, 7, None)));
206        let e = resolve_in("nobody", PASSWD, GROUP).unwrap_err();
207        assert!(e.contains("no such user") && e.contains("nobody"), "{e}");
208        let e = resolve_in("dev:wheel", PASSWD, GROUP).unwrap_err();
209        assert!(e.contains("no such group") && e.contains("wheel"), "{e}");
210    }
211
212    #[test]
213    fn modes_and_numeric_ids() {
214        assert_eq!(parse_mode("0770"), Ok(0o770));
215        assert_eq!(parse_mode("2775"), Ok(0o2775));
216        assert_eq!(parse_mode("0o750"), Ok(0o750));
217        assert!(parse_mode("0890").is_err());
218        assert!(parse_mode("17777").is_err());
219        assert_eq!(numeric_ids("1000"), Some((1000, 1000)));
220        assert_eq!(numeric_ids("1000:50"), Some((1000, 50)));
221        assert_eq!(numeric_ids("dev"), None);
222        assert_eq!(numeric_ids("1000:staff"), None);
223    }
224
225    #[test]
226    fn parents() {
227        assert_eq!(parent("/home/dev/.cache/x"), "/home/dev/.cache");
228        assert_eq!(parent("/data"), "/");
229        assert_eq!(parent("/data/"), "/");
230    }
231}