299 lines
9.4 KiB
Rust
299 lines
9.4 KiB
Rust
use std::{
|
|
collections::HashMap,
|
|
fs,
|
|
path::Path,
|
|
};
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum AccountFormat {
|
|
Redox,
|
|
Unix,
|
|
}
|
|
|
|
/// Detect whether a passwd/shadow/group line uses Redox (`;`) or Unix (`:`) delimiters.
|
|
pub fn detect_format(line: &str) -> AccountFormat {
|
|
if line.contains(';') {
|
|
AccountFormat::Redox
|
|
} else {
|
|
AccountFormat::Unix
|
|
}
|
|
}
|
|
|
|
/// Split a line into fields according to its detected format.
|
|
pub fn split_fields(line: &str) -> (AccountFormat, Vec<&str>) {
|
|
let format = detect_format(line);
|
|
let delimiter = match format {
|
|
AccountFormat::Redox => ';',
|
|
AccountFormat::Unix => ':',
|
|
};
|
|
(format, line.split(delimiter).collect())
|
|
}
|
|
|
|
/// Parse uid and gid from passwd-format fields.
|
|
pub fn parse_uid_gid(parts: &[&str], format: AccountFormat) -> Option<(u32, u32)> {
|
|
let (uid_index, gid_index) = match format {
|
|
AccountFormat::Redox if parts.len() >= 3 => (1, 2),
|
|
AccountFormat::Unix if parts.len() >= 4 => (2, 3),
|
|
_ => return None,
|
|
};
|
|
|
|
let uid = parts[uid_index].parse::<u32>().ok()?;
|
|
let gid = parts[gid_index].parse::<u32>().ok()?;
|
|
Some((uid, gid))
|
|
}
|
|
|
|
/// Load the uid/gid pair for a given username from `/etc/passwd`.
|
|
pub fn load_uid_gid(username: &str) -> Result<(u32, u32), String> {
|
|
let passwd = fs::read_to_string("/etc/passwd")
|
|
.map_err(|err| format!("failed to read /etc/passwd: {err}"))?;
|
|
for line in passwd.lines() {
|
|
let trimmed = line.trim();
|
|
if trimmed.is_empty() || trimmed.starts_with('#') {
|
|
continue;
|
|
}
|
|
let (format, parts) = split_fields(trimmed);
|
|
if parts.len() < 3 || parts[0] != username {
|
|
continue;
|
|
}
|
|
if let Some((uid, gid)) = parse_uid_gid(&parts, format) {
|
|
return Ok((uid, gid));
|
|
}
|
|
return Err(format!("invalid uid/gid for user '{username}'"));
|
|
}
|
|
Err(format!("unknown user '{username}'"))
|
|
}
|
|
|
|
/// A full account entry as found in `/etc/passwd`.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct Account {
|
|
pub username: String,
|
|
pub uid: u32,
|
|
pub gid: u32,
|
|
pub home: String,
|
|
pub shell: String,
|
|
}
|
|
|
|
/// Parse the contents of `/etc/passwd` into a map keyed by username.
|
|
pub fn parse_passwd(contents: &str) -> Result<HashMap<String, Account>, String> {
|
|
let mut accounts = HashMap::new();
|
|
|
|
for (index, raw_line) in contents.lines().enumerate() {
|
|
let line = raw_line.trim();
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
|
|
let (format, parts) = split_fields(line);
|
|
let (uid_index, gid_index, home_index, shell_index) = match format {
|
|
AccountFormat::Redox if parts.len() >= 6 => (1, 2, 4, 5),
|
|
AccountFormat::Unix if parts.len() >= 7 => (2, 3, 5, 6),
|
|
AccountFormat::Redox => {
|
|
return Err(format!("invalid Redox passwd entry on line {}", index + 1))
|
|
}
|
|
AccountFormat::Unix => {
|
|
return Err(format!("invalid passwd entry on line {}", index + 1))
|
|
}
|
|
};
|
|
|
|
let uid = parts[uid_index]
|
|
.parse::<u32>()
|
|
.map_err(|_| format!("invalid uid on line {}", index + 1))?;
|
|
let gid = parts[gid_index]
|
|
.parse::<u32>()
|
|
.map_err(|_| format!("invalid gid on line {}", index + 1))?;
|
|
|
|
accounts.insert(
|
|
parts[0].to_string(),
|
|
Account {
|
|
username: parts[0].to_string(),
|
|
uid,
|
|
gid,
|
|
home: parts[home_index].to_string(),
|
|
shell: parts[shell_index].to_string(),
|
|
},
|
|
);
|
|
}
|
|
|
|
Ok(accounts)
|
|
}
|
|
|
|
/// Load a single account by username from `/etc/passwd`.
|
|
pub fn load_account(username: &str) -> Result<Account, String> {
|
|
let passwd = fs::read_to_string("/etc/passwd")
|
|
.map_err(|err| format!("failed to read /etc/passwd: {err}"))?;
|
|
let accounts = parse_passwd(&passwd)?;
|
|
accounts
|
|
.get(username)
|
|
.cloned()
|
|
.ok_or_else(|| format!("unknown user '{username}'"))
|
|
}
|
|
|
|
/// Load shadow password hashes from `/etc/shadow`.
|
|
pub fn load_shadow_passwords() -> Result<HashMap<String, String>, String> {
|
|
if !Path::new("/etc/shadow").exists() {
|
|
return Ok(HashMap::new());
|
|
}
|
|
|
|
let mut passwords = HashMap::new();
|
|
let contents = fs::read_to_string("/etc/shadow")
|
|
.map_err(|err| format!("failed to read /etc/shadow: {err}"))?;
|
|
for (index, raw_line) in contents.lines().enumerate() {
|
|
let line = raw_line.trim();
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
|
|
let (_format, parts) = split_fields(line);
|
|
if parts.len() < 2 {
|
|
return Err(format!("invalid shadow entry on line {}", index + 1));
|
|
}
|
|
passwords.insert(parts[0].to_string(), parts[1].to_string());
|
|
}
|
|
Ok(passwords)
|
|
}
|
|
|
|
/// A group entry as found in `/etc/group`.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct GroupEntry {
|
|
pub gid: u32,
|
|
pub members: Vec<String>,
|
|
}
|
|
|
|
/// Parse the contents of `/etc/group` into a vector of group entries.
|
|
pub fn parse_groups(contents: &str) -> Result<Vec<GroupEntry>, String> {
|
|
let mut groups = Vec::new();
|
|
|
|
for (index, raw_line) in contents.lines().enumerate() {
|
|
let line = raw_line.trim();
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
|
|
let (_format, parts) = split_fields(line);
|
|
if parts.len() < 4 {
|
|
return Err(format!("invalid group entry on line {}", index + 1));
|
|
}
|
|
|
|
let gid = parts[2]
|
|
.parse::<u32>()
|
|
.map_err(|_| format!("invalid group gid on line {}", index + 1))?;
|
|
let members = if parts[3].is_empty() {
|
|
Vec::new()
|
|
} else {
|
|
parts[3].split(',').map(str::to_string).collect::<Vec<_>>()
|
|
};
|
|
|
|
groups.push(GroupEntry { gid, members });
|
|
}
|
|
|
|
Ok(groups)
|
|
}
|
|
|
|
/// Load supplementary group IDs for a user, including the primary gid.
|
|
pub fn load_supplementary_groups(username: &str, primary_gid: u32) -> Result<Vec<u32>, String> {
|
|
let Ok(group_contents) = fs::read_to_string("/etc/group") else {
|
|
return Ok(vec![primary_gid]);
|
|
};
|
|
|
|
let mut groups = parse_groups(&group_contents)?
|
|
.into_iter()
|
|
.filter(|entry| entry.gid == primary_gid || entry.members.iter().any(|member| member == username))
|
|
.map(|entry| entry.gid)
|
|
.collect::<Vec<_>>();
|
|
groups.sort_unstable();
|
|
groups.dedup();
|
|
if groups.is_empty() {
|
|
groups.push(primary_gid);
|
|
}
|
|
Ok(groups)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn detect_format_redox() {
|
|
assert_eq!(detect_format("a;b;c"), AccountFormat::Redox);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_format_unix() {
|
|
assert_eq!(detect_format("a:b:c"), AccountFormat::Unix);
|
|
}
|
|
|
|
#[test]
|
|
fn split_fields_redox() {
|
|
let (format, parts) = split_fields("greeter;101;101;Greeter;/nonexistent;/usr/bin/ion");
|
|
assert_eq!(format, AccountFormat::Redox);
|
|
assert_eq!(parts[0], "greeter");
|
|
assert_eq!(parts[2], "101");
|
|
}
|
|
|
|
#[test]
|
|
fn split_fields_unix() {
|
|
let (format, parts) = split_fields("root:x:0:0:root:/root:/usr/bin/ion");
|
|
assert_eq!(format, AccountFormat::Unix);
|
|
assert_eq!(parts[2], "0");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_uid_gid_redox() {
|
|
assert_eq!(
|
|
parse_uid_gid(&["greeter", "101", "101", "Greeter", "/nonexistent", "/usr/bin/ion"], AccountFormat::Redox),
|
|
Some((101, 101))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_uid_gid_unix() {
|
|
assert_eq!(
|
|
parse_uid_gid(&["root", "x", "0", "0", "root", "/root", "/usr/bin/ion"], AccountFormat::Unix),
|
|
Some((0, 0))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_passwd_basic() {
|
|
let accounts = parse_passwd("root:x:0:0:root:/root:/usr/bin/ion\nuser:x:1000:1000:User:/home/user:/usr/bin/ion\n")
|
|
.expect("passwd should parse");
|
|
assert_eq!(accounts["root"].uid, 0);
|
|
assert_eq!(accounts["user"].home, "/home/user");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_passwd_redox_style() {
|
|
let accounts = parse_passwd("greeter;101;101;Greeter;/nonexistent;/usr/bin/ion\n")
|
|
.expect("redox passwd layout should parse");
|
|
let greeter = accounts.get("greeter").expect("greeter entry should exist");
|
|
assert_eq!(greeter.uid, 101);
|
|
assert_eq!(greeter.gid, 101);
|
|
assert_eq!(greeter.home, "/nonexistent");
|
|
assert_eq!(greeter.shell, "/usr/bin/ion");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_groups_collects_members() {
|
|
let groups = parse_groups("sudo:x:1:user,root\nusers:x:1000:user\n").expect("group should parse");
|
|
assert_eq!(groups[0].gid, 1);
|
|
assert_eq!(groups[0].members, vec![String::from("user"), String::from("root")]);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_groups_redox_style() {
|
|
let groups = parse_groups("greeter;x;101;greeter\n").expect("redox group should parse");
|
|
assert_eq!(groups[0].gid, 101);
|
|
assert_eq!(groups[0].members, vec![String::from("greeter")]);
|
|
}
|
|
|
|
#[test]
|
|
fn load_supplementary_groups_includes_primary() {
|
|
let groups = parse_groups("users:x:1000:user\n").expect("group should parse");
|
|
let gids: Vec<u32> = groups.into_iter()
|
|
.filter(|g| g.members.iter().any(|m| m == "user"))
|
|
.map(|g| g.gid)
|
|
.collect();
|
|
assert!(gids.contains(&1000));
|
|
}
|
|
}
|