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 = random_base36(4);
129 format!("{}-{}{}", prefix, timestamp_base36, random)
131 .chars()
132 .take(31)
133 .collect()
134}
135
136fn random_base36(length: usize) -> String {
145 let mut out = String::with_capacity(length);
146 while out.len() < length {
147 let uuid = uuid::Uuid::new_v4();
148 for (index, byte) in uuid.as_bytes().iter().enumerate() {
149 if out.len() == length {
150 break;
151 }
152 if index == 6 || index == 8 {
154 continue;
155 }
156 if *byte >= 252 {
158 continue;
159 }
160 let idx = byte % 36;
161 out.push(if idx < 10 {
162 (b'0' + idx) as char
163 } else {
164 (b'a' + idx - 10) as char
165 });
166 }
167 }
168 out
169}
170
171#[derive(Debug, Default)]
173pub struct CreateUserOptions {
174 pub no_login: bool,
176 pub home_dir: Option<String>,
178}
179
180pub fn create_user(
183 username: &str,
184 groups: &[String],
185 options: &CreateUserOptions,
186) -> UserOperationResult {
187 if cfg!(windows) {
188 return UserOperationResult {
189 success: false,
190 message: "User creation is not supported on Windows".to_string(),
191 username: Some(username.to_string()),
192 ..Default::default()
193 };
194 }
195
196 if user_exists(username) {
197 return UserOperationResult {
198 success: true,
199 message: format!("User \"{}\" already exists", username),
200 username: Some(username.to_string()),
201 already_exists: true,
202 ..Default::default()
203 };
204 }
205
206 let mut cmd = Command::new("sudo");
208 cmd.arg("-n").arg("useradd");
209
210 if let Some(ref home) = options.home_dir {
212 cmd.arg("-d").arg(home);
213 }
214 cmd.arg("-m"); if options.no_login {
218 cmd.arg("-s").arg("/usr/sbin/nologin");
219 } else {
220 cmd.arg("-s").arg("/bin/bash");
221 }
222
223 let existing_groups: Vec<&String> = groups.iter().filter(|g| group_exists(g)).collect();
225
226 if !existing_groups.is_empty() {
227 let groups_str: Vec<&str> = existing_groups.iter().map(|s| s.as_str()).collect();
228 cmd.arg("-G").arg(groups_str.join(","));
229 }
230
231 cmd.arg(username);
233
234 if is_debug() {
235 eprintln!("[DEBUG] Creating user: {:?}", cmd);
236 eprintln!(
237 "[DEBUG] Groups to add: {}",
238 existing_groups
239 .iter()
240 .map(|s| s.as_str())
241 .collect::<Vec<_>>()
242 .join(", ")
243 );
244 }
245
246 match cmd.output() {
247 Ok(output) => {
248 if output.status.success() {
249 UserOperationResult {
250 success: true,
251 message: format!(
252 "Created user \"{}\" with groups: {}",
253 username,
254 if existing_groups.is_empty() {
255 "none".to_string()
256 } else {
257 existing_groups
258 .iter()
259 .map(|s| s.as_str())
260 .collect::<Vec<_>>()
261 .join(", ")
262 }
263 ),
264 username: Some(username.to_string()),
265 groups: Some(existing_groups.into_iter().cloned().collect()),
266 ..Default::default()
267 }
268 } else {
269 let stderr = String::from_utf8_lossy(&output.stderr);
270 UserOperationResult {
271 success: false,
272 message: format!(
273 "Failed to create user: {}",
274 if stderr.trim().is_empty() {
275 "Unknown error"
276 } else {
277 stderr.trim()
278 }
279 ),
280 username: Some(username.to_string()),
281 ..Default::default()
282 }
283 }
284 }
285 Err(e) => UserOperationResult {
286 success: false,
287 message: format!("Failed to create user: {}", e),
288 username: Some(username.to_string()),
289 ..Default::default()
290 },
291 }
292}
293
294#[derive(Debug, Default)]
296pub struct CreateIsolatedUserOptions {
297 pub include_groups: Option<Vec<String>>,
299 pub exclude_groups: Option<Vec<String>>,
301 pub no_login: bool,
303}
304
305pub fn create_isolated_user(
307 custom_username: Option<&str>,
308 options: &CreateIsolatedUserOptions,
309) -> UserOperationResult {
310 let username = custom_username
311 .map(String::from)
312 .unwrap_or_else(|| generate_isolated_username(None));
313
314 let mut groups = get_current_user_groups();
315
316 if let Some(ref include) = options.include_groups {
318 groups.retain(|g| include.contains(g));
319 }
320
321 if let Some(ref exclude) = options.exclude_groups {
322 groups.retain(|g| !exclude.contains(g));
323 }
324
325 let important_groups = ["sudo", "docker", "wheel", "admin"];
327 let current_groups = get_current_user_groups();
328 let inherited_important: Vec<&str> = important_groups
329 .iter()
330 .copied()
331 .filter(|g| current_groups.iter().any(|cg| cg == *g))
332 .collect();
333
334 if is_debug() {
335 eprintln!("[DEBUG] Current user groups: {}", current_groups.join(", "));
336 eprintln!("[DEBUG] Groups to inherit: {}", groups.join(", "));
337 eprintln!(
338 "[DEBUG] Important groups found: {}",
339 inherited_important.join(", ")
340 );
341 }
342
343 create_user(
344 &username,
345 &groups,
346 &CreateUserOptions {
347 no_login: options.no_login,
348 ..Default::default()
349 },
350 )
351}
352
353#[derive(Debug, Default)]
355pub struct DeleteUserOptions {
356 pub remove_home: bool,
358}
359
360pub fn delete_user(username: &str, options: &DeleteUserOptions) -> UserOperationResult {
363 if cfg!(windows) {
364 return UserOperationResult {
365 success: false,
366 message: "User deletion is not supported on Windows".to_string(),
367 ..Default::default()
368 };
369 }
370
371 if !user_exists(username) {
372 return UserOperationResult {
373 success: true,
374 message: format!("User \"{}\" does not exist", username),
375 ..Default::default()
376 };
377 }
378
379 let mut cmd = Command::new("sudo");
380 cmd.arg("-n").arg("userdel");
381
382 if options.remove_home {
383 cmd.arg("-r"); }
385
386 cmd.arg(username);
387
388 if is_debug() {
389 eprintln!("[DEBUG] Deleting user: {:?}", cmd);
390 }
391
392 match cmd.output() {
393 Ok(output) => {
394 if output.status.success() {
395 UserOperationResult {
396 success: true,
397 message: format!("Deleted user \"{}\"", username),
398 ..Default::default()
399 }
400 } else {
401 let stderr = String::from_utf8_lossy(&output.stderr);
402 UserOperationResult {
403 success: false,
404 message: format!(
405 "Failed to delete user: {}",
406 if stderr.trim().is_empty() {
407 "Unknown error"
408 } else {
409 stderr.trim()
410 }
411 ),
412 ..Default::default()
413 }
414 }
415 }
416 Err(e) => UserOperationResult {
417 success: false,
418 message: format!("Failed to delete user: {}", e),
419 ..Default::default()
420 },
421 }
422}
423
424pub fn get_user_info(username: &str) -> UserInfo {
426 if !user_exists(username) {
427 return UserInfo {
428 exists: false,
429 ..Default::default()
430 };
431 }
432
433 let mut info = UserInfo {
434 exists: true,
435 ..Default::default()
436 };
437
438 if let Ok(output) = Command::new("id").arg(username).output() {
440 if output.status.success() {
441 let output_str = String::from_utf8_lossy(&output.stdout);
442 if let Some(uid_match) = output_str
444 .split_whitespace()
445 .next()
446 .and_then(|s| s.strip_prefix("uid="))
447 {
448 if let Some(uid_str) = uid_match.split('(').next() {
449 info.uid = uid_str.parse().ok();
450 }
451 }
452 if let Some(gid_part) = output_str.split_whitespace().nth(1) {
453 if let Some(gid_match) = gid_part.strip_prefix("gid=") {
454 if let Some(gid_str) = gid_match.split('(').next() {
455 info.gid = gid_str.parse().ok();
456 }
457 }
458 }
459 }
460 }
461
462 if let Ok(output) = Command::new("groups").arg(username).output() {
464 if output.status.success() {
465 let output_str = String::from_utf8_lossy(&output.stdout);
466 let groups_part = output_str.split(':').next_back().unwrap_or(&output_str);
467 info.groups = Some(
468 groups_part
469 .split_whitespace()
470 .filter(|s| !s.is_empty())
471 .map(String::from)
472 .collect(),
473 );
474 }
475 }
476
477 if let Ok(output) = Command::new("getent").args(["passwd", username]).output() {
479 if output.status.success() {
480 let output_str = String::from_utf8_lossy(&output.stdout);
481 let parts: Vec<&str> = output_str.trim().split(':').collect();
482 if parts.len() >= 7 {
483 info.home = Some(parts[5].to_string());
484 info.shell = Some(parts[6].to_string());
485 }
486 }
487 }
488
489 info
490}
491
492pub fn has_sudo_access() -> bool {
494 Command::new("sudo")
495 .args(["-n", "true"])
496 .stdout(std::process::Stdio::null())
497 .stderr(std::process::Stdio::null())
498 .status()
499 .map(|s| s.success())
500 .unwrap_or(false)
501}
502
503fn is_debug() -> bool {
504 env::var("START_DEBUG").is_ok_and(|v| v == "1" || v == "true")
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[test]
512 fn test_get_current_user() {
513 let user = get_current_user();
514 assert!(!user.is_empty());
515 assert_ne!(user, "unknown");
516 }
517
518 #[test]
519 fn test_get_current_user_groups() {
520 if cfg!(windows) {
522 return;
523 }
524 let groups = get_current_user_groups();
525 assert!(!groups.is_empty());
527 }
528
529 #[test]
530 fn test_generate_isolated_username() {
531 let name1 = generate_isolated_username(None);
532 let name2 = generate_isolated_username(None);
533 assert!(name1.starts_with("start-"));
534 assert!(name1.len() <= 31);
535 assert_ne!(name1, name2);
537 }
538
539 #[test]
540 fn test_generate_isolated_username_with_prefix() {
541 let name = generate_isolated_username(Some("test"));
542 assert!(name.starts_with("test-"));
543 }
544
545 #[test]
546 fn test_user_exists_root() {
547 if !cfg!(windows) {
549 assert!(user_exists("root"));
550 }
551 }
552
553 #[test]
554 fn test_user_not_exists() {
555 assert!(!user_exists("this_user_definitely_does_not_exist_12345"));
556 }
557
558 #[test]
559 fn test_group_exists() {
560 if !cfg!(windows) {
562 let found_group = group_exists("root")
564 || group_exists("wheel")
565 || group_exists("sudo")
566 || group_exists("admin")
567 || group_exists("staff");
568 assert!(
569 found_group,
570 "Expected at least one common group (root/wheel/sudo/admin/staff) to exist"
571 );
572 }
573 }
574}