Skip to main content

disk_core/
id_names.rs

1//! Resolve numeric uid/gid to names by reading the image's own `/etc/passwd`
2//! and `/etc/group`.
3//!
4//! A Linux disk image *contains* the mapping it was built with, so we don't have
5//! to guess: `/etc/passwd` maps uid → user name, `/etc/group` maps gid → group
6//! name. Showing `root` / `www-data` instead of `0` / `33` in the browse view
7//! and File Info is a small thing that makes a Linux rootfs far more legible.
8//!
9//! This is **best-effort and advisory**. Plenty of images have no `/etc`
10//! (a data partition, a non-Linux filesystem, a bare application squashfs), and
11//! an id with no entry simply falls back to the number. Nothing here is load-
12//! bearing — it never blocks an operation, and a wrong or missing name changes
13//! only what's displayed, never what's written.
14
15use std::collections::HashMap;
16
17use crate::entry::EntryType;
18use crate::filesystem::Filesystem;
19
20/// A cap on how much of `/etc/passwd` or `/etc/group` we read. These files are
21/// kilobytes even on a busy system; anything larger is not the file we think it
22/// is, so stop rather than pull a huge blob into memory.
23const MAX_DB_BYTES: usize = 4 * 1024 * 1024;
24
25/// uid → user name and gid → group name, parsed from an image's account files.
26#[derive(Debug, Default, Clone)]
27pub struct IdNameMap {
28    users: HashMap<u32, String>,
29    groups: HashMap<u32, String>,
30}
31
32impl IdNameMap {
33    /// Read `/etc/passwd` and `/etc/group` from `fs` and build the maps. Missing
34    /// or unreadable files yield an empty map rather than an error — this is a
35    /// display nicety, never a hard dependency.
36    pub fn from_filesystem(fs: &mut dyn Filesystem) -> Self {
37        let mut map = Self::default();
38        if let Some(bytes) = read_file_at_path(fs, &["etc", "passwd"]) {
39            map.users = parse_colon_db(&bytes);
40        }
41        if let Some(bytes) = read_file_at_path(fs, &["etc", "group"]) {
42            map.groups = parse_colon_db(&bytes);
43        }
44        map
45    }
46
47    /// True when neither file yielded a single entry — the caller should just
48    /// show raw numbers.
49    pub fn is_empty(&self) -> bool {
50        self.users.is_empty() && self.groups.is_empty()
51    }
52
53    /// The user name for `uid`, if known.
54    pub fn user(&self, uid: u32) -> Option<&str> {
55        self.users.get(&uid).map(String::as_str)
56    }
57
58    /// The group name for `gid`, if known.
59    pub fn group(&self, gid: u32) -> Option<&str> {
60        self.groups.get(&gid).map(String::as_str)
61    }
62
63    /// `"root:wheel (0:0)"` when both names are known, degrading gracefully to
64    /// `"root:0 (0:0)"` or `"0:0"` as names are missing. Always shows the raw
65    /// numbers so nothing is hidden.
66    pub fn format_owner(&self, uid: u32, gid: u32) -> String {
67        match (self.user(uid), self.group(gid)) {
68            (Some(u), Some(g)) => format!("{u}:{g} ({uid}:{gid})"),
69            (Some(u), None) => format!("{u}:{gid} ({uid}:{gid})"),
70            (None, Some(g)) => format!("{uid}:{g} ({uid}:{gid})"),
71            (None, None) => format!("{uid}:{gid}"),
72        }
73    }
74}
75
76/// Parse a colon-separated account database (`/etc/passwd` or `/etc/group`)
77/// into `id -> name`. Both files share the shape `name:x:id:...`, so one parser
78/// covers both. Blank lines, comments (`#`), and malformed lines are skipped.
79fn parse_colon_db(bytes: &[u8]) -> HashMap<u32, String> {
80    let mut out = HashMap::new();
81    let text = String::from_utf8_lossy(bytes);
82    for line in text.lines() {
83        let line = line.trim();
84        if line.is_empty() || line.starts_with('#') {
85            continue;
86        }
87        let mut fields = line.split(':');
88        let (Some(name), Some(_passwd), Some(id_str)) =
89            (fields.next(), fields.next(), fields.next())
90        else {
91            continue;
92        };
93        if name.is_empty() {
94            continue;
95        }
96        if let Ok(id) = id_str.trim().parse::<u32>() {
97            // First entry wins — matches getpwuid/getgrgid, which return the
98            // first matching line for a duplicated id.
99            out.entry(id).or_insert_with(|| name.to_string());
100        }
101    }
102    out
103}
104
105/// Walk `fs` from its root along `components` and read the file there. Returns
106/// `None` for any missing component, a non-file target, or a read error — all
107/// of which just mean "no mapping available".
108fn read_file_at_path(fs: &mut dyn Filesystem, components: &[&str]) -> Option<Vec<u8>> {
109    let mut current = fs.root().ok()?;
110    for (i, comp) in components.iter().enumerate() {
111        let is_last = i + 1 == components.len();
112        let children = fs.list_directory(&current).ok()?;
113        let next = children.into_iter().find(|e| &e.name == comp)?;
114        if is_last {
115            // `/etc/passwd` is commonly a symlink on some distros; the FS
116            // read_file follows or returns the target, but for our purposes a
117            // plain regular file is what we expect.
118            if next.entry_type != EntryType::File {
119                return None;
120            }
121            return fs.read_file(&next, MAX_DB_BYTES).ok();
122        }
123        if next.entry_type != EntryType::Directory {
124            return None;
125        }
126        current = next;
127    }
128    None
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn parses_passwd_and_group() {
137        let passwd = b"# comment\nroot:x:0:0:root:/root:/bin/bash\n\
138                       daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n\
139                       www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin\n\
140                       malformed line without colons\n";
141        let users = parse_colon_db(passwd);
142        assert_eq!(users.get(&0).map(String::as_str), Some("root"));
143        assert_eq!(users.get(&33).map(String::as_str), Some("www-data"));
144        assert_eq!(users.len(), 3, "comment + malformed line must be skipped");
145
146        let group = b"root:x:0:\nwheel:x:0:\n\
147                      staff:x:50:member1,member2\n";
148        let groups = parse_colon_db(group);
149        // First entry for a duplicated gid wins (root before wheel).
150        assert_eq!(groups.get(&0).map(String::as_str), Some("root"));
151        assert_eq!(groups.get(&50).map(String::as_str), Some("staff"));
152    }
153
154    #[test]
155    fn format_owner_degrades_gracefully() {
156        let mut m = IdNameMap::default();
157        m.users.insert(0, "root".into());
158        m.groups.insert(0, "wheel".into());
159        assert_eq!(m.format_owner(0, 0), "root:wheel (0:0)");
160        // Known user, unknown group.
161        assert_eq!(m.format_owner(0, 99), "root:99 (0:99)");
162        // Unknown user, known group.
163        assert_eq!(m.format_owner(0, 0), "root:wheel (0:0)");
164        m.users.remove(&0);
165        assert_eq!(m.format_owner(0, 0), "0:wheel (0:0)");
166        m.groups.remove(&0);
167        assert_eq!(m.format_owner(0, 0), "0:0");
168    }
169
170    #[test]
171    fn empty_when_no_entries() {
172        assert!(IdNameMap::default().is_empty());
173    }
174
175    /// A blank/whitespace name field must not produce an empty-string name.
176    #[test]
177    fn rejects_empty_name_field() {
178        let db = b":x:5:\n";
179        assert!(parse_colon_db(db).is_empty());
180    }
181}