1use std::collections::HashMap;
16
17use crate::entry::EntryType;
18use crate::filesystem::Filesystem;
19
20const MAX_DB_BYTES: usize = 4 * 1024 * 1024;
24
25#[derive(Debug, Default, Clone)]
27pub struct IdNameMap {
28 users: HashMap<u32, String>,
29 groups: HashMap<u32, String>,
30}
31
32impl IdNameMap {
33 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 pub fn is_empty(&self) -> bool {
50 self.users.is_empty() && self.groups.is_empty()
51 }
52
53 pub fn user(&self, uid: u32) -> Option<&str> {
55 self.users.get(&uid).map(String::as_str)
56 }
57
58 pub fn group(&self, gid: u32) -> Option<&str> {
60 self.groups.get(&gid).map(String::as_str)
61 }
62
63 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
76fn 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 out.entry(id).or_insert_with(|| name.to_string());
100 }
101 }
102 out
103}
104
105fn 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(¤t).ok()?;
113 let next = children.into_iter().find(|e| &e.name == comp)?;
114 if is_last {
115 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 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 assert_eq!(m.format_owner(0, 99), "root:99 (0:99)");
162 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 #[test]
177 fn rejects_empty_name_field() {
178 let db = b":x:5:\n";
179 assert!(parse_colon_db(db).is_empty());
180 }
181}