Skip to main content

start_command/
user_manager.rs

1//! User Manager for start-command
2//!
3//! Provides utilities for creating isolated users with the same
4//! group memberships as the current user. This enables true user
5//! isolation while preserving access to sudo, docker, and other
6//! privileged groups.
7
8use std::env;
9use std::process::Command;
10
11/// Result of a user operation
12#[derive(Debug, Default)]
13pub struct UserOperationResult {
14    /// Whether the operation succeeded
15    pub success: bool,
16    /// Message describing the result
17    pub message: String,
18    /// Username (for create operations)
19    pub username: Option<String>,
20    /// Groups assigned (for create operations)
21    pub groups: Option<Vec<String>>,
22    /// Whether the user already existed
23    pub already_exists: bool,
24}
25
26/// User information
27#[derive(Debug, Default)]
28pub struct UserInfo {
29    /// Whether the user exists
30    pub exists: bool,
31    /// User ID
32    pub uid: Option<u32>,
33    /// Group ID
34    pub gid: Option<u32>,
35    /// Groups the user belongs to
36    pub groups: Option<Vec<String>>,
37    /// Home directory
38    pub home: Option<String>,
39    /// Shell
40    pub shell: Option<String>,
41}
42
43/// Get the current user's username
44pub fn get_current_user() -> String {
45    // Try whoami command
46    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    // Fallback to environment variables
53    env::var("USER")
54        .or_else(|_| env::var("USERNAME"))
55        .unwrap_or_else(|_| "unknown".to_string())
56}
57
58/// Get the groups the current user belongs to
59pub 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            // Output format: "user : group1 group2 group3" or "group1 group2 group3"
64            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
80/// Check if a user exists on the system
81pub 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
91/// Check if a group exists on the system
92pub fn group_exists(groupname: &str) -> bool {
93    // Try getent first (Linux)
94    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    // Fallback to dscl for macOS
106    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
120/// Generate a unique username for isolation
121pub 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    // Keep username short (max 31 chars)
139    format!("{}-{}{}", prefix, timestamp_base36, random)
140        .chars()
141        .take(31)
142        .collect()
143}
144
145/// Simple random number generator
146fn 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/// Options for creating a user
169#[derive(Debug, Default)]
170pub struct CreateUserOptions {
171    /// If true, create user with nologin shell
172    pub no_login: bool,
173    /// Home directory (default: /home/username)
174    pub home_dir: Option<String>,
175}
176
177/// Create a new user with specified groups
178/// Requires sudo access
179pub 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    // Build useradd command
204    let mut cmd = Command::new("sudo");
205    cmd.arg("-n").arg("useradd");
206
207    // Add home directory option
208    if let Some(ref home) = options.home_dir {
209        cmd.arg("-d").arg(home);
210    }
211    cmd.arg("-m"); // Create home directory
212
213    // Add shell option
214    if options.no_login {
215        cmd.arg("-s").arg("/usr/sbin/nologin");
216    } else {
217        cmd.arg("-s").arg("/bin/bash");
218    }
219
220    // Filter groups to only existing ones
221    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    // Add username
229    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/// Options for creating an isolated user
292#[derive(Debug, Default)]
293pub struct CreateIsolatedUserOptions {
294    /// Only include these groups
295    pub include_groups: Option<Vec<String>>,
296    /// Exclude these groups
297    pub exclude_groups: Option<Vec<String>>,
298    /// Create with nologin shell
299    pub no_login: bool,
300}
301
302/// Create an isolated user with the same groups as the current user
303pub 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    // Filter groups if specified
314    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    // Important groups for isolation to work properly
323    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/// Options for deleting a user
351#[derive(Debug, Default)]
352pub struct DeleteUserOptions {
353    /// Remove home directory
354    pub remove_home: bool,
355}
356
357/// Delete a user and optionally their home directory
358/// Requires sudo access
359pub 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"); // Remove home directory
381    }
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
421/// Get information about a user
422pub 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    // Get uid and gid from id command
436    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            // Parse: uid=1000(user) gid=1000(group) groups=...
440            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    // Get groups
460    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    // Get home and shell from getent passwd
475    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
489/// Check if the current process has sudo access without password
490pub 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        // The groups command is Unix-specific, skip on Windows
518        if cfg!(windows) {
519            return;
520        }
521        let groups = get_current_user_groups();
522        // Should have at least one group (the user's primary group)
523        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        // Names should be different (with high probability)
533        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        // Root should exist on Unix systems
545        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        // On most Unix systems, at least one of these groups should exist
558        if !cfg!(windows) {
559            // Linux typically has root/sudo, macOS has wheel/admin/staff
560            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}