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 = random_base36(4);
129    // Keep username short (max 31 chars)
130    format!("{}-{}{}", prefix, timestamp_base36, random)
131        .chars()
132        .take(31)
133        .collect()
134}
135
136/// Generate `length` cryptographically random base36 characters.
137///
138/// `uuid::Uuid::new_v4` draws from the operating-system CSPRNG through
139/// `getrandom`. The time-seeded xorshift generator this replaced made the only
140/// unguessable part of an isolation account name predictable - the same defect
141/// CodeQL reports as `js/insecure-randomness` on the JavaScript side, which no
142/// Rust query covers (issue #168). Mirrors `randomBase36` in
143/// `js/src/lib/user-manager.js`.
144fn 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            // Bytes 6 and 8 carry the fixed version and variant nibbles.
153            if index == 6 || index == 8 {
154                continue;
155            }
156            // Rejection sampling: 252 = 36 * 7, so the remainder stays uniform.
157            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/// Options for creating a user
172#[derive(Debug, Default)]
173pub struct CreateUserOptions {
174    /// If true, create user with nologin shell
175    pub no_login: bool,
176    /// Home directory (default: /home/username)
177    pub home_dir: Option<String>,
178}
179
180/// Create a new user with specified groups
181/// Requires sudo access
182pub 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    // Build useradd command
207    let mut cmd = Command::new("sudo");
208    cmd.arg("-n").arg("useradd");
209
210    // Add home directory option
211    if let Some(ref home) = options.home_dir {
212        cmd.arg("-d").arg(home);
213    }
214    cmd.arg("-m"); // Create home directory
215
216    // Add shell option
217    if options.no_login {
218        cmd.arg("-s").arg("/usr/sbin/nologin");
219    } else {
220        cmd.arg("-s").arg("/bin/bash");
221    }
222
223    // Filter groups to only existing ones
224    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    // Add username
232    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/// Options for creating an isolated user
295#[derive(Debug, Default)]
296pub struct CreateIsolatedUserOptions {
297    /// Only include these groups
298    pub include_groups: Option<Vec<String>>,
299    /// Exclude these groups
300    pub exclude_groups: Option<Vec<String>>,
301    /// Create with nologin shell
302    pub no_login: bool,
303}
304
305/// Create an isolated user with the same groups as the current user
306pub 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    // Filter groups if specified
317    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    // Important groups for isolation to work properly
326    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/// Options for deleting a user
354#[derive(Debug, Default)]
355pub struct DeleteUserOptions {
356    /// Remove home directory
357    pub remove_home: bool,
358}
359
360/// Delete a user and optionally their home directory
361/// Requires sudo access
362pub 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"); // Remove home directory
384    }
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
424/// Get information about a user
425pub 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    // Get uid and gid from id command
439    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            // Parse: uid=1000(user) gid=1000(group) groups=...
443            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    // Get groups
463    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    // Get home and shell from getent passwd
478    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
492/// Check if the current process has sudo access without password
493pub 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        // The groups command is Unix-specific, skip on Windows
521        if cfg!(windows) {
522            return;
523        }
524        let groups = get_current_user_groups();
525        // Should have at least one group (the user's primary group)
526        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        // Names should be different (with high probability)
536        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        // Root should exist on Unix systems
548        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        // On most Unix systems, at least one of these groups should exist
561        if !cfg!(windows) {
562            // Linux typically has root/sudo, macOS has wheel/admin/staff
563            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}