1use std::env;
9use std::process::Command;
10
11#[derive(Debug, Default)]
13pub struct UserOperationResult {
14 pub success: bool,
16 pub message: String,
18 pub username: Option<String>,
20 pub groups: Option<Vec<String>>,
22 pub already_exists: bool,
24}
25
26#[derive(Debug, Default)]
28pub struct UserInfo {
29 pub exists: bool,
31 pub uid: Option<u32>,
33 pub gid: Option<u32>,
35 pub groups: Option<Vec<String>>,
37 pub home: Option<String>,
39 pub shell: Option<String>,
41}
42
43pub fn get_current_user() -> String {
45 if let Ok(output) = Command::new("whoami").output() {
47 if output.status.success() {
48 return String::from_utf8_lossy(&output.stdout).trim().to_string();
49 }
50 }
51
52 env::var("USER")
54 .or_else(|_| env::var("USERNAME"))
55 .unwrap_or_else(|_| "unknown".to_string())
56}
57
58pub fn get_current_user_groups() -> Vec<String> {
60 if let Ok(output) = Command::new("groups").output() {
61 if output.status.success() {
62 let output_str = String::from_utf8_lossy(&output.stdout);
63 let parts: Vec<&str> = output_str.split(':').collect();
65 let groups_part = if parts.len() > 1 { parts[1] } else { parts[0] };
66 return groups_part
67 .split_whitespace()
68 .filter(|s| !s.is_empty())
69 .map(String::from)
70 .collect();
71 }
72 }
73
74 if is_debug() {
75 eprintln!("[DEBUG] Failed to get user groups");
76 }
77 Vec::new()
78}
79
80pub fn user_exists(username: &str) -> bool {
82 Command::new("id")
83 .arg(username)
84 .stdout(std::process::Stdio::null())
85 .stderr(std::process::Stdio::null())
86 .status()
87 .map(|s| s.success())
88 .unwrap_or(false)
89}
90
91pub fn group_exists(groupname: &str) -> bool {
93 if let Ok(status) = Command::new("getent")
95 .args(["group", groupname])
96 .stdout(std::process::Stdio::null())
97 .stderr(std::process::Stdio::null())
98 .status()
99 {
100 if status.success() {
101 return true;
102 }
103 }
104
105 if let Ok(status) = Command::new("dscl")
107 .args([".", "-read", &format!("/Groups/{}", groupname)])
108 .stdout(std::process::Stdio::null())
109 .stderr(std::process::Stdio::null())
110 .status()
111 {
112 if status.success() {
113 return true;
114 }
115 }
116
117 false
118}
119
120pub fn generate_isolated_username(prefix: Option<&str>) -> String {
122 let prefix = prefix.unwrap_or("start");
123 let timestamp = std::time::SystemTime::now()
124 .duration_since(std::time::UNIX_EPOCH)
125 .unwrap()
126 .as_millis();
127 let timestamp_base36 = format!("{:x}", timestamp);
128 let random: String = (0..4)
129 .map(|_| {
130 let idx = simple_random() % 36;
131 if idx < 10 {
132 (b'0' + idx as u8) as char
133 } else {
134 (b'a' + (idx - 10) as u8) as char
135 }
136 })
137 .collect();
138 format!("{}-{}{}", prefix, timestamp_base36, random)
140 .chars()
141 .take(31)
142 .collect()
143}
144
145fn simple_random() -> usize {
147 use std::cell::RefCell;
148 use std::time::{SystemTime, UNIX_EPOCH};
149
150 thread_local! {
151 static STATE: RefCell<u64> = RefCell::new(
152 SystemTime::now()
153 .duration_since(UNIX_EPOCH)
154 .unwrap()
155 .as_nanos() as u64
156 );
157 }
158
159 STATE.with(|state| {
160 let mut s = state.borrow_mut();
161 *s ^= *s << 13;
162 *s ^= *s >> 7;
163 *s ^= *s << 17;
164 (*s % 1000) as usize
165 })
166}
167
168#[derive(Debug, Default)]
170pub struct CreateUserOptions {
171 pub no_login: bool,
173 pub home_dir: Option<String>,
175}
176
177pub fn create_user(
180 username: &str,
181 groups: &[String],
182 options: &CreateUserOptions,
183) -> UserOperationResult {
184 if cfg!(windows) {
185 return UserOperationResult {
186 success: false,
187 message: "User creation is not supported on Windows".to_string(),
188 username: Some(username.to_string()),
189 ..Default::default()
190 };
191 }
192
193 if user_exists(username) {
194 return UserOperationResult {
195 success: true,
196 message: format!("User \"{}\" already exists", username),
197 username: Some(username.to_string()),
198 already_exists: true,
199 ..Default::default()
200 };
201 }
202
203 let mut cmd = Command::new("sudo");
205 cmd.arg("-n").arg("useradd");
206
207 if let Some(ref home) = options.home_dir {
209 cmd.arg("-d").arg(home);
210 }
211 cmd.arg("-m"); if options.no_login {
215 cmd.arg("-s").arg("/usr/sbin/nologin");
216 } else {
217 cmd.arg("-s").arg("/bin/bash");
218 }
219
220 let existing_groups: Vec<&String> = groups.iter().filter(|g| group_exists(g)).collect();
222
223 if !existing_groups.is_empty() {
224 let groups_str: Vec<&str> = existing_groups.iter().map(|s| s.as_str()).collect();
225 cmd.arg("-G").arg(groups_str.join(","));
226 }
227
228 cmd.arg(username);
230
231 if is_debug() {
232 eprintln!("[DEBUG] Creating user: {:?}", cmd);
233 eprintln!(
234 "[DEBUG] Groups to add: {}",
235 existing_groups
236 .iter()
237 .map(|s| s.as_str())
238 .collect::<Vec<_>>()
239 .join(", ")
240 );
241 }
242
243 match cmd.output() {
244 Ok(output) => {
245 if output.status.success() {
246 UserOperationResult {
247 success: true,
248 message: format!(
249 "Created user \"{}\" with groups: {}",
250 username,
251 if existing_groups.is_empty() {
252 "none".to_string()
253 } else {
254 existing_groups
255 .iter()
256 .map(|s| s.as_str())
257 .collect::<Vec<_>>()
258 .join(", ")
259 }
260 ),
261 username: Some(username.to_string()),
262 groups: Some(existing_groups.into_iter().cloned().collect()),
263 ..Default::default()
264 }
265 } else {
266 let stderr = String::from_utf8_lossy(&output.stderr);
267 UserOperationResult {
268 success: false,
269 message: format!(
270 "Failed to create user: {}",
271 if stderr.trim().is_empty() {
272 "Unknown error"
273 } else {
274 stderr.trim()
275 }
276 ),
277 username: Some(username.to_string()),
278 ..Default::default()
279 }
280 }
281 }
282 Err(e) => UserOperationResult {
283 success: false,
284 message: format!("Failed to create user: {}", e),
285 username: Some(username.to_string()),
286 ..Default::default()
287 },
288 }
289}
290
291#[derive(Debug, Default)]
293pub struct CreateIsolatedUserOptions {
294 pub include_groups: Option<Vec<String>>,
296 pub exclude_groups: Option<Vec<String>>,
298 pub no_login: bool,
300}
301
302pub fn create_isolated_user(
304 custom_username: Option<&str>,
305 options: &CreateIsolatedUserOptions,
306) -> UserOperationResult {
307 let username = custom_username
308 .map(String::from)
309 .unwrap_or_else(|| generate_isolated_username(None));
310
311 let mut groups = get_current_user_groups();
312
313 if let Some(ref include) = options.include_groups {
315 groups.retain(|g| include.contains(g));
316 }
317
318 if let Some(ref exclude) = options.exclude_groups {
319 groups.retain(|g| !exclude.contains(g));
320 }
321
322 let important_groups = ["sudo", "docker", "wheel", "admin"];
324 let current_groups = get_current_user_groups();
325 let inherited_important: Vec<&str> = important_groups
326 .iter()
327 .copied()
328 .filter(|g| current_groups.iter().any(|cg| cg == *g))
329 .collect();
330
331 if is_debug() {
332 eprintln!("[DEBUG] Current user groups: {}", current_groups.join(", "));
333 eprintln!("[DEBUG] Groups to inherit: {}", groups.join(", "));
334 eprintln!(
335 "[DEBUG] Important groups found: {}",
336 inherited_important.join(", ")
337 );
338 }
339
340 create_user(
341 &username,
342 &groups,
343 &CreateUserOptions {
344 no_login: options.no_login,
345 ..Default::default()
346 },
347 )
348}
349
350#[derive(Debug, Default)]
352pub struct DeleteUserOptions {
353 pub remove_home: bool,
355}
356
357pub fn delete_user(username: &str, options: &DeleteUserOptions) -> UserOperationResult {
360 if cfg!(windows) {
361 return UserOperationResult {
362 success: false,
363 message: "User deletion is not supported on Windows".to_string(),
364 ..Default::default()
365 };
366 }
367
368 if !user_exists(username) {
369 return UserOperationResult {
370 success: true,
371 message: format!("User \"{}\" does not exist", username),
372 ..Default::default()
373 };
374 }
375
376 let mut cmd = Command::new("sudo");
377 cmd.arg("-n").arg("userdel");
378
379 if options.remove_home {
380 cmd.arg("-r"); }
382
383 cmd.arg(username);
384
385 if is_debug() {
386 eprintln!("[DEBUG] Deleting user: {:?}", cmd);
387 }
388
389 match cmd.output() {
390 Ok(output) => {
391 if output.status.success() {
392 UserOperationResult {
393 success: true,
394 message: format!("Deleted user \"{}\"", username),
395 ..Default::default()
396 }
397 } else {
398 let stderr = String::from_utf8_lossy(&output.stderr);
399 UserOperationResult {
400 success: false,
401 message: format!(
402 "Failed to delete user: {}",
403 if stderr.trim().is_empty() {
404 "Unknown error"
405 } else {
406 stderr.trim()
407 }
408 ),
409 ..Default::default()
410 }
411 }
412 }
413 Err(e) => UserOperationResult {
414 success: false,
415 message: format!("Failed to delete user: {}", e),
416 ..Default::default()
417 },
418 }
419}
420
421pub fn get_user_info(username: &str) -> UserInfo {
423 if !user_exists(username) {
424 return UserInfo {
425 exists: false,
426 ..Default::default()
427 };
428 }
429
430 let mut info = UserInfo {
431 exists: true,
432 ..Default::default()
433 };
434
435 if let Ok(output) = Command::new("id").arg(username).output() {
437 if output.status.success() {
438 let output_str = String::from_utf8_lossy(&output.stdout);
439 if let Some(uid_match) = output_str
441 .split_whitespace()
442 .next()
443 .and_then(|s| s.strip_prefix("uid="))
444 {
445 if let Some(uid_str) = uid_match.split('(').next() {
446 info.uid = uid_str.parse().ok();
447 }
448 }
449 if let Some(gid_part) = output_str.split_whitespace().nth(1) {
450 if let Some(gid_match) = gid_part.strip_prefix("gid=") {
451 if let Some(gid_str) = gid_match.split('(').next() {
452 info.gid = gid_str.parse().ok();
453 }
454 }
455 }
456 }
457 }
458
459 if let Ok(output) = Command::new("groups").arg(username).output() {
461 if output.status.success() {
462 let output_str = String::from_utf8_lossy(&output.stdout);
463 let groups_part = output_str.split(':').next_back().unwrap_or(&output_str);
464 info.groups = Some(
465 groups_part
466 .split_whitespace()
467 .filter(|s| !s.is_empty())
468 .map(String::from)
469 .collect(),
470 );
471 }
472 }
473
474 if let Ok(output) = Command::new("getent").args(["passwd", username]).output() {
476 if output.status.success() {
477 let output_str = String::from_utf8_lossy(&output.stdout);
478 let parts: Vec<&str> = output_str.trim().split(':').collect();
479 if parts.len() >= 7 {
480 info.home = Some(parts[5].to_string());
481 info.shell = Some(parts[6].to_string());
482 }
483 }
484 }
485
486 info
487}
488
489pub fn has_sudo_access() -> bool {
491 Command::new("sudo")
492 .args(["-n", "true"])
493 .stdout(std::process::Stdio::null())
494 .stderr(std::process::Stdio::null())
495 .status()
496 .map(|s| s.success())
497 .unwrap_or(false)
498}
499
500fn is_debug() -> bool {
501 env::var("START_DEBUG").is_ok_and(|v| v == "1" || v == "true")
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507
508 #[test]
509 fn test_get_current_user() {
510 let user = get_current_user();
511 assert!(!user.is_empty());
512 assert_ne!(user, "unknown");
513 }
514
515 #[test]
516 fn test_get_current_user_groups() {
517 if cfg!(windows) {
519 return;
520 }
521 let groups = get_current_user_groups();
522 assert!(!groups.is_empty());
524 }
525
526 #[test]
527 fn test_generate_isolated_username() {
528 let name1 = generate_isolated_username(None);
529 let name2 = generate_isolated_username(None);
530 assert!(name1.starts_with("start-"));
531 assert!(name1.len() <= 31);
532 assert_ne!(name1, name2);
534 }
535
536 #[test]
537 fn test_generate_isolated_username_with_prefix() {
538 let name = generate_isolated_username(Some("test"));
539 assert!(name.starts_with("test-"));
540 }
541
542 #[test]
543 fn test_user_exists_root() {
544 if !cfg!(windows) {
546 assert!(user_exists("root"));
547 }
548 }
549
550 #[test]
551 fn test_user_not_exists() {
552 assert!(!user_exists("this_user_definitely_does_not_exist_12345"));
553 }
554
555 #[test]
556 fn test_group_exists() {
557 if !cfg!(windows) {
559 let found_group = group_exists("root")
561 || group_exists("wheel")
562 || group_exists("sudo")
563 || group_exists("admin")
564 || group_exists("staff");
565 assert!(
566 found_group,
567 "Expected at least one common group (root/wheel/sudo/admin/staff) to exist"
568 );
569 }
570 }
571}