use std::env;
use std::process::Command;
#[derive(Debug, Default)]
pub struct UserOperationResult {
pub success: bool,
pub message: String,
pub username: Option<String>,
pub groups: Option<Vec<String>>,
pub already_exists: bool,
}
#[derive(Debug, Default)]
pub struct UserInfo {
pub exists: bool,
pub uid: Option<u32>,
pub gid: Option<u32>,
pub groups: Option<Vec<String>>,
pub home: Option<String>,
pub shell: Option<String>,
}
pub fn get_current_user() -> String {
if let Ok(output) = Command::new("whoami").output() {
if output.status.success() {
return String::from_utf8_lossy(&output.stdout).trim().to_string();
}
}
env::var("USER")
.or_else(|_| env::var("USERNAME"))
.unwrap_or_else(|_| "unknown".to_string())
}
pub fn get_current_user_groups() -> Vec<String> {
if let Ok(output) = Command::new("groups").output() {
if output.status.success() {
let output_str = String::from_utf8_lossy(&output.stdout);
let parts: Vec<&str> = output_str.split(':').collect();
let groups_part = if parts.len() > 1 { parts[1] } else { parts[0] };
return groups_part
.split_whitespace()
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
}
}
if is_debug() {
eprintln!("[DEBUG] Failed to get user groups");
}
Vec::new()
}
pub fn user_exists(username: &str) -> bool {
Command::new("id")
.arg(username)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn group_exists(groupname: &str) -> bool {
if let Ok(status) = Command::new("getent")
.args(["group", groupname])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
{
if status.success() {
return true;
}
}
if let Ok(status) = Command::new("dscl")
.args([".", "-read", &format!("/Groups/{}", groupname)])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
{
if status.success() {
return true;
}
}
false
}
pub fn generate_isolated_username(prefix: Option<&str>) -> String {
let prefix = prefix.unwrap_or("start");
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
let timestamp_base36 = format!("{:x}", timestamp);
let random: String = (0..4)
.map(|_| {
let idx = simple_random() % 36;
if idx < 10 {
(b'0' + idx as u8) as char
} else {
(b'a' + (idx - 10) as u8) as char
}
})
.collect();
format!("{}-{}{}", prefix, timestamp_base36, random)
.chars()
.take(31)
.collect()
}
fn simple_random() -> usize {
use std::cell::RefCell;
use std::time::{SystemTime, UNIX_EPOCH};
thread_local! {
static STATE: RefCell<u64> = RefCell::new(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos() as u64
);
}
STATE.with(|state| {
let mut s = state.borrow_mut();
*s ^= *s << 13;
*s ^= *s >> 7;
*s ^= *s << 17;
(*s % 1000) as usize
})
}
#[derive(Debug, Default)]
pub struct CreateUserOptions {
pub no_login: bool,
pub home_dir: Option<String>,
}
pub fn create_user(
username: &str,
groups: &[String],
options: &CreateUserOptions,
) -> UserOperationResult {
if cfg!(windows) {
return UserOperationResult {
success: false,
message: "User creation is not supported on Windows".to_string(),
username: Some(username.to_string()),
..Default::default()
};
}
if user_exists(username) {
return UserOperationResult {
success: true,
message: format!("User \"{}\" already exists", username),
username: Some(username.to_string()),
already_exists: true,
..Default::default()
};
}
let mut cmd = Command::new("sudo");
cmd.arg("-n").arg("useradd");
if let Some(ref home) = options.home_dir {
cmd.arg("-d").arg(home);
}
cmd.arg("-m");
if options.no_login {
cmd.arg("-s").arg("/usr/sbin/nologin");
} else {
cmd.arg("-s").arg("/bin/bash");
}
let existing_groups: Vec<&String> = groups.iter().filter(|g| group_exists(g)).collect();
if !existing_groups.is_empty() {
let groups_str: Vec<&str> = existing_groups.iter().map(|s| s.as_str()).collect();
cmd.arg("-G").arg(groups_str.join(","));
}
cmd.arg(username);
if is_debug() {
eprintln!("[DEBUG] Creating user: {:?}", cmd);
eprintln!(
"[DEBUG] Groups to add: {}",
existing_groups
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
match cmd.output() {
Ok(output) => {
if output.status.success() {
UserOperationResult {
success: true,
message: format!(
"Created user \"{}\" with groups: {}",
username,
if existing_groups.is_empty() {
"none".to_string()
} else {
existing_groups
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
}
),
username: Some(username.to_string()),
groups: Some(existing_groups.into_iter().cloned().collect()),
..Default::default()
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
UserOperationResult {
success: false,
message: format!(
"Failed to create user: {}",
if stderr.trim().is_empty() {
"Unknown error"
} else {
stderr.trim()
}
),
username: Some(username.to_string()),
..Default::default()
}
}
}
Err(e) => UserOperationResult {
success: false,
message: format!("Failed to create user: {}", e),
username: Some(username.to_string()),
..Default::default()
},
}
}
#[derive(Debug, Default)]
pub struct CreateIsolatedUserOptions {
pub include_groups: Option<Vec<String>>,
pub exclude_groups: Option<Vec<String>>,
pub no_login: bool,
}
pub fn create_isolated_user(
custom_username: Option<&str>,
options: &CreateIsolatedUserOptions,
) -> UserOperationResult {
let username = custom_username
.map(String::from)
.unwrap_or_else(|| generate_isolated_username(None));
let mut groups = get_current_user_groups();
if let Some(ref include) = options.include_groups {
groups.retain(|g| include.contains(g));
}
if let Some(ref exclude) = options.exclude_groups {
groups.retain(|g| !exclude.contains(g));
}
let important_groups = ["sudo", "docker", "wheel", "admin"];
let current_groups = get_current_user_groups();
let inherited_important: Vec<&str> = important_groups
.iter()
.copied()
.filter(|g| current_groups.iter().any(|cg| cg == *g))
.collect();
if is_debug() {
eprintln!("[DEBUG] Current user groups: {}", current_groups.join(", "));
eprintln!("[DEBUG] Groups to inherit: {}", groups.join(", "));
eprintln!(
"[DEBUG] Important groups found: {}",
inherited_important.join(", ")
);
}
create_user(
&username,
&groups,
&CreateUserOptions {
no_login: options.no_login,
..Default::default()
},
)
}
#[derive(Debug, Default)]
pub struct DeleteUserOptions {
pub remove_home: bool,
}
pub fn delete_user(username: &str, options: &DeleteUserOptions) -> UserOperationResult {
if cfg!(windows) {
return UserOperationResult {
success: false,
message: "User deletion is not supported on Windows".to_string(),
..Default::default()
};
}
if !user_exists(username) {
return UserOperationResult {
success: true,
message: format!("User \"{}\" does not exist", username),
..Default::default()
};
}
let mut cmd = Command::new("sudo");
cmd.arg("-n").arg("userdel");
if options.remove_home {
cmd.arg("-r"); }
cmd.arg(username);
if is_debug() {
eprintln!("[DEBUG] Deleting user: {:?}", cmd);
}
match cmd.output() {
Ok(output) => {
if output.status.success() {
UserOperationResult {
success: true,
message: format!("Deleted user \"{}\"", username),
..Default::default()
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
UserOperationResult {
success: false,
message: format!(
"Failed to delete user: {}",
if stderr.trim().is_empty() {
"Unknown error"
} else {
stderr.trim()
}
),
..Default::default()
}
}
}
Err(e) => UserOperationResult {
success: false,
message: format!("Failed to delete user: {}", e),
..Default::default()
},
}
}
pub fn get_user_info(username: &str) -> UserInfo {
if !user_exists(username) {
return UserInfo {
exists: false,
..Default::default()
};
}
let mut info = UserInfo {
exists: true,
..Default::default()
};
if let Ok(output) = Command::new("id").arg(username).output() {
if output.status.success() {
let output_str = String::from_utf8_lossy(&output.stdout);
if let Some(uid_match) = output_str
.split_whitespace()
.next()
.and_then(|s| s.strip_prefix("uid="))
{
if let Some(uid_str) = uid_match.split('(').next() {
info.uid = uid_str.parse().ok();
}
}
if let Some(gid_part) = output_str.split_whitespace().nth(1) {
if let Some(gid_match) = gid_part.strip_prefix("gid=") {
if let Some(gid_str) = gid_match.split('(').next() {
info.gid = gid_str.parse().ok();
}
}
}
}
}
if let Ok(output) = Command::new("groups").arg(username).output() {
if output.status.success() {
let output_str = String::from_utf8_lossy(&output.stdout);
let groups_part = output_str.split(':').next_back().unwrap_or(&output_str);
info.groups = Some(
groups_part
.split_whitespace()
.filter(|s| !s.is_empty())
.map(String::from)
.collect(),
);
}
}
if let Ok(output) = Command::new("getent").args(["passwd", username]).output() {
if output.status.success() {
let output_str = String::from_utf8_lossy(&output.stdout);
let parts: Vec<&str> = output_str.trim().split(':').collect();
if parts.len() >= 7 {
info.home = Some(parts[5].to_string());
info.shell = Some(parts[6].to_string());
}
}
}
info
}
pub fn has_sudo_access() -> bool {
Command::new("sudo")
.args(["-n", "true"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn is_debug() -> bool {
env::var("START_DEBUG").is_ok_and(|v| v == "1" || v == "true")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_current_user() {
let user = get_current_user();
assert!(!user.is_empty());
assert_ne!(user, "unknown");
}
#[test]
fn test_get_current_user_groups() {
if cfg!(windows) {
return;
}
let groups = get_current_user_groups();
assert!(!groups.is_empty());
}
#[test]
fn test_generate_isolated_username() {
let name1 = generate_isolated_username(None);
let name2 = generate_isolated_username(None);
assert!(name1.starts_with("start-"));
assert!(name1.len() <= 31);
assert_ne!(name1, name2);
}
#[test]
fn test_generate_isolated_username_with_prefix() {
let name = generate_isolated_username(Some("test"));
assert!(name.starts_with("test-"));
}
#[test]
fn test_user_exists_root() {
if !cfg!(windows) {
assert!(user_exists("root"));
}
}
#[test]
fn test_user_not_exists() {
assert!(!user_exists("this_user_definitely_does_not_exist_12345"));
}
#[test]
fn test_group_exists() {
if !cfg!(windows) {
let found_group = group_exists("root")
|| group_exists("wheel")
|| group_exists("sudo")
|| group_exists("admin")
|| group_exists("staff");
assert!(
found_group,
"Expected at least one common group (root/wheel/sudo/admin/staff) to exist"
);
}
}
}