use std::collections::HashMap;
use crate::entry::EntryType;
use crate::filesystem::Filesystem;
const MAX_DB_BYTES: usize = 4 * 1024 * 1024;
#[derive(Debug, Default, Clone)]
pub struct IdNameMap {
users: HashMap<u32, String>,
groups: HashMap<u32, String>,
}
impl IdNameMap {
pub fn from_filesystem(fs: &mut dyn Filesystem) -> Self {
let mut map = Self::default();
if let Some(bytes) = read_file_at_path(fs, &["etc", "passwd"]) {
map.users = parse_colon_db(&bytes);
}
if let Some(bytes) = read_file_at_path(fs, &["etc", "group"]) {
map.groups = parse_colon_db(&bytes);
}
map
}
pub fn is_empty(&self) -> bool {
self.users.is_empty() && self.groups.is_empty()
}
pub fn user(&self, uid: u32) -> Option<&str> {
self.users.get(&uid).map(String::as_str)
}
pub fn group(&self, gid: u32) -> Option<&str> {
self.groups.get(&gid).map(String::as_str)
}
pub fn format_owner(&self, uid: u32, gid: u32) -> String {
match (self.user(uid), self.group(gid)) {
(Some(u), Some(g)) => format!("{u}:{g} ({uid}:{gid})"),
(Some(u), None) => format!("{u}:{gid} ({uid}:{gid})"),
(None, Some(g)) => format!("{uid}:{g} ({uid}:{gid})"),
(None, None) => format!("{uid}:{gid}"),
}
}
}
fn parse_colon_db(bytes: &[u8]) -> HashMap<u32, String> {
let mut out = HashMap::new();
let text = String::from_utf8_lossy(bytes);
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut fields = line.split(':');
let (Some(name), Some(_passwd), Some(id_str)) =
(fields.next(), fields.next(), fields.next())
else {
continue;
};
if name.is_empty() {
continue;
}
if let Ok(id) = id_str.trim().parse::<u32>() {
out.entry(id).or_insert_with(|| name.to_string());
}
}
out
}
fn read_file_at_path(fs: &mut dyn Filesystem, components: &[&str]) -> Option<Vec<u8>> {
let mut current = fs.root().ok()?;
for (i, comp) in components.iter().enumerate() {
let is_last = i + 1 == components.len();
let children = fs.list_directory(¤t).ok()?;
let next = children.into_iter().find(|e| &e.name == comp)?;
if is_last {
if next.entry_type != EntryType::File {
return None;
}
return fs.read_file(&next, MAX_DB_BYTES).ok();
}
if next.entry_type != EntryType::Directory {
return None;
}
current = next;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_passwd_and_group() {
let passwd = b"# comment\nroot:x:0:0:root:/root:/bin/bash\n\
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n\
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin\n\
malformed line without colons\n";
let users = parse_colon_db(passwd);
assert_eq!(users.get(&0).map(String::as_str), Some("root"));
assert_eq!(users.get(&33).map(String::as_str), Some("www-data"));
assert_eq!(users.len(), 3, "comment + malformed line must be skipped");
let group = b"root:x:0:\nwheel:x:0:\n\
staff:x:50:member1,member2\n";
let groups = parse_colon_db(group);
assert_eq!(groups.get(&0).map(String::as_str), Some("root"));
assert_eq!(groups.get(&50).map(String::as_str), Some("staff"));
}
#[test]
fn format_owner_degrades_gracefully() {
let mut m = IdNameMap::default();
m.users.insert(0, "root".into());
m.groups.insert(0, "wheel".into());
assert_eq!(m.format_owner(0, 0), "root:wheel (0:0)");
assert_eq!(m.format_owner(0, 99), "root:99 (0:99)");
assert_eq!(m.format_owner(0, 0), "root:wheel (0:0)");
m.users.remove(&0);
assert_eq!(m.format_owner(0, 0), "0:wheel (0:0)");
m.groups.remove(&0);
assert_eq!(m.format_owner(0, 0), "0:0");
}
#[test]
fn empty_when_no_entries() {
assert!(IdNameMap::default().is_empty());
}
#[test]
fn rejects_empty_name_field() {
let db = b":x:5:\n";
assert!(parse_colon_db(db).is_empty());
}
}