Skip to main content

regent_sdk/state/attribute/system/
user.rs

1//! User account management attribute
2//!
3//! This module provides the `UserExpectedState` type for managing user accounts
4//! on Unix-like systems and Windows.
5//!
6//! **Compatible OS:**
7//! - Linux (all distributions) - uses useradd/usermod/userdel
8//! - Windows (when `windows` feature is enabled) - uses net user commands
9//!
10//! # Examples
11//!
12//! ## Rust API
13//!
14//! ```no_run
15//! use regent_sdk::state::attribute::system::user::{UserDetails, UserExpectedState};
16//! use regent_sdk::{Attribute, ExpectedState, Privilege};
17//!
18//! // Create a user with specific properties
19//! let alice = UserExpectedState::present_with_details(
20//!     "alice",
21//!     UserDetails::default()
22//!         .with_uid(1001)
23//!         .with_shell("/bin/bash")
24//!         .with_home("/home/alice")
25//!         .with_comment("Alice Smith")
26//!         .finish(),
27//! );
28//!
29//! // Remove a user along with its home directory
30//! let bob = UserExpectedState::absent_remove_home("bob");
31//!
32//! let expected_state = ExpectedState::new()
33//!     .with_attribute(Attribute::user(alice, Privilege::WithSudo, None))
34//!     .with_attribute(Attribute::user(bob, Privilege::WithSudo, None))
35//!     .build();
36//! ```
37//!
38//! ## YAML API
39//!
40//! ```yaml
41//! Attributes:
42//!   - Name: User alice must be present
43//!     Privilege: !WithSudo
44//!     Detail: !User
45//!       Name: alice
46//!       State: !Present
47//!         Uid: 1001
48//!         Shell: /bin/bash
49//!         Home: /home/alice
50//!         Comment: Alice Smith
51//! ```
52
53use crate::error::RegentError;
54use crate::hosts::managed_host::InternalApiCallOutcome;
55use crate::hosts::managed_host::{AssessCompliance, ReachCompliance, Timeout};
56use crate::hosts::properties::{HostProperties, InitSystem, LinuxFlavor, LinuxSpecifics, OsKind};
57use crate::secrets::SecretProvidersPool;
58use crate::state::Check;
59use crate::state::attribute::HostHandler;
60use crate::state::attribute::Privilege;
61use crate::state::attribute::Remediation;
62use crate::state::attribute::RemediationsList;
63use crate::state::compliance::AttributeComplianceAssessment;
64use serde::{Deserialize, Serialize};
65use std::time::Duration;
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68#[serde(rename_all = "PascalCase")]
69pub enum UserExpectedStatus {
70    Absent {
71        /// Whether to remove home directory when deleting user
72        remove_home: Option<bool>,
73    },
74    #[serde(rename_all = "PascalCase")]
75    Present { details: UserDetails },
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79#[serde(deny_unknown_fields)]
80#[serde(rename_all = "PascalCase")]
81pub struct UserDetails {
82    uid: Option<u32>,
83    /// Primary group name
84    group: Option<String>,
85    /// Supplementary groups the user should belong to
86    groups: Option<Vec<String>>,
87    /// Whether to append to existing supplementary groups instead of replacing them
88    append: Option<bool>,
89    shell: Option<String>,
90    home: Option<String>,
91    /// GECOS comment field
92    comment: Option<String>,
93    /// Hashed password
94    password: Option<String>,
95    /// Whether this is a system user (no login shell, no home directory by default)
96    system: Option<bool>,
97    /// Whether to create home directory when creating user
98    create_home: Option<bool>,
99}
100
101impl UserDetails {
102    pub fn default() -> Self {
103        Self {
104            uid: None,
105            group: None,
106            groups: None,
107            append: None,
108            shell: None,
109            home: None,
110            comment: None,
111            password: None,
112            system: None,
113            create_home: None,
114        }
115    }
116
117    pub fn with_uid(&mut self, uid: u32) -> &mut Self {
118        self.uid = Some(uid);
119        self
120    }
121
122    pub fn with_group(&mut self, group: &str) -> &mut Self {
123        self.group = Some(group.to_string());
124        self
125    }
126
127    pub fn with_groups(&mut self, groups: Vec<String>) -> &mut Self {
128        self.groups = Some(groups);
129        self
130    }
131
132    pub fn with_append(&mut self, append: bool) -> &mut Self {
133        self.append = Some(append);
134        self
135    }
136
137    pub fn with_shell(&mut self, shell: &str) -> &mut Self {
138        self.shell = Some(shell.to_string());
139        self
140    }
141
142    pub fn with_home(&mut self, home: &str) -> &mut Self {
143        self.home = Some(home.to_string());
144        self
145    }
146
147    pub fn with_comment(&mut self, comment: &str) -> &mut Self {
148        self.comment = Some(comment.to_string());
149        self
150    }
151
152    pub fn with_password(&mut self, password: &str) -> &mut Self {
153        self.password = Some(password.to_string());
154        self
155    }
156
157    pub fn with_system(&mut self, system: bool) -> &mut Self {
158        self.system = Some(system);
159        self
160    }
161
162    pub fn with_create_home(&mut self, create_home: bool) -> &mut Self {
163        self.create_home = Some(create_home);
164        self
165    }
166
167    pub fn finish(&mut self) -> Self {
168        self.clone()
169    }
170}
171
172/// Configuration for a user account
173///
174/// The `append` field controls whether supplementary groups are appended to existing groups
175/// or replaced entirely.
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177#[serde(deny_unknown_fields)]
178#[serde(rename_all = "PascalCase")]
179pub struct UserExpectedState {
180    name: String,
181    /// Desired state of the user (defaults to Present if not specified)
182    state: UserExpectedStatus,
183}
184
185impl Timeout for UserExpectedState {
186    fn default_timeout(&self) -> Duration {
187        Duration::from_secs(5)
188    }
189}
190
191impl UserExpectedState {
192    pub fn absent(&mut self, username: &str, remove_home: Option<bool>) -> UserExpectedState {
193        UserExpectedState {
194            name: username.to_string(),
195            state: UserExpectedStatus::Absent { remove_home },
196        }
197    }
198
199    /// Create a configuration for a user that must not exist, keeping its home directory
200    pub fn absent_keep_home(username: &str) -> UserExpectedState {
201        UserExpectedState {
202            name: username.to_string(),
203            state: UserExpectedStatus::Absent {
204                remove_home: Some(false),
205            },
206        }
207    }
208
209    /// Create a configuration for a user that must not exist, removing its home directory too
210    pub fn absent_remove_home(username: &str) -> UserExpectedState {
211        UserExpectedState {
212            name: username.to_string(),
213            state: UserExpectedStatus::Absent {
214                remove_home: Some(true),
215            },
216        }
217    }
218
219    pub fn present_with_defaults(username: &str) -> UserExpectedState {
220        UserExpectedState {
221            name: username.to_string(),
222            state: UserExpectedStatus::Present {
223                details: UserDetails::default(),
224            },
225        }
226    }
227
228    pub fn present_with_details(username: &str, details: UserDetails) -> UserExpectedState {
229        UserExpectedState {
230            name: username.to_string(),
231            state: UserExpectedStatus::Present { details },
232        }
233    }
234}
235
236impl Check for UserExpectedState {
237    fn check(&self) -> Result<(), RegentError> {
238        Ok(())
239    }
240
241    fn check_host_compatibility(
242        &self,
243        host_properties: &HostProperties,
244    ) -> Result<(), RegentError> {
245        match host_properties.os_kind() {
246            OsKind::Linux(_) => Ok(()),
247            #[cfg(feature = "windows")]
248            OsKind::Windows(_) => Ok(()),
249            incompatible_os_kind => Err(RegentError::IncompatibleHost(format!(
250                "Host is {:?} but user management is only supported on Linux or Windows (with windows feature)",
251                incompatible_os_kind
252            ))),
253        }
254    }
255}
256
257impl<Handler: HostHandler> AssessCompliance<Handler> for UserExpectedState {
258    async fn assess_compliance(
259        &self,
260        host_handler: &mut Handler,
261        host_properties: &Option<HostProperties>,
262        privilege: &Privilege,
263        _optional_secret_provider: &Option<SecretProvidersPool>,
264    ) -> Result<AttributeComplianceAssessment, RegentError> {
265        if let Some(props) = host_properties {
266            self.check_host_compatibility(props)?;
267        }
268
269        // Determine the effective OS kind - assume Linux if HostProperties is None
270        let os_kind = host_properties
271            .as_ref()
272            .map(|props| props.os_kind())
273            .unwrap_or(&OsKind::Linux(LinuxSpecifics {
274                linux_flavor: LinuxFlavor::Debian,
275                init_system: InitSystem::Systemd,
276            }));
277
278        let user_exists = match os_kind {
279            #[cfg(feature = "windows")]
280            OsKind::Windows(_) => match windows_user_exists(host_handler, &self.name).await {
281                Ok(exists) => exists,
282                Err(e) => return Err(RegentError::FailedDryRunEvaluation(e)),
283            },
284            OsKind::Linux(_) => match user_exists(host_handler, &self.name).await {
285                Ok(exists) => exists,
286                Err(e) => return Err(RegentError::FailedDryRunEvaluation(e)),
287            },
288            OsKind::MacOs(_) | OsKind::FreeBsd(_) | OsKind::Unknown => {
289                return Err(RegentError::FailedDryRunEvaluation(format!(
290                    "User management is not supported on {:?}",
291                    os_kind
292                )));
293            }
294        };
295
296        match &self.state {
297            UserExpectedStatus::Absent { remove_home } => {
298                if !user_exists {
299                    return Ok(AttributeComplianceAssessment::Compliant);
300                }
301                Ok(AttributeComplianceAssessment::NonCompliant(
302                    RemediationsList::from(vec![Remediation::User(UserApiCall::from(
303                        UserModuleInternalApiCall::Delete {
304                            username: self.name.clone(),
305                            remove_home: remove_home.unwrap_or(false),
306                        },
307                        privilege.clone(),
308                    ))])
309                    .unwrap(),
310                ))
311            }
312            UserExpectedStatus::Present { details } => {
313                if !user_exists {
314                    return Ok(AttributeComplianceAssessment::NonCompliant(
315                        RemediationsList::from(vec![Remediation::User(UserApiCall::from(
316                            UserModuleInternalApiCall::Add {
317                                username: self.name.clone(),
318                                uid: details.uid,
319                                group: details.group.clone(),
320                                groups: details.groups.clone(),
321                                shell: details.shell.clone(),
322                                home: details.home.clone(),
323                                comment: details.comment.clone(),
324                                password: details.password.clone(),
325                                system: details.system.unwrap_or(false),
326                                create_home: details.create_home.unwrap_or(true),
327                            },
328                            privilege.clone(),
329                        ))])
330                        .unwrap(),
331                    ));
332                }
333
334                // For Windows, many user properties don't map directly, so we'll do a simpler check
335                // On Unix systems, we can check detailed properties
336                match os_kind {
337                    #[cfg(feature = "windows")]
338                    OsKind::Windows(_) => Ok(AttributeComplianceAssessment::Compliant),
339                    OsKind::Linux(_) => {
340                        let mut mod_uid: Option<u32> = None;
341                        let mut mod_group: Option<String> = None;
342                        let mut mod_groups: Option<Vec<String>> = None;
343                        let mut mod_shell: Option<String> = None;
344                        let mut mod_home: Option<String> = None;
345                        let mut mod_comment: Option<String> = None;
346                        let append = details.append.unwrap_or(true);
347
348                        let passwd_entry = match get_passwd_entry(host_handler, &self.name).await {
349                            Ok(entry) => entry,
350                            Err(e) => return Err(RegentError::FailedDryRunEvaluation(e)),
351                        };
352
353                        if let Some(expected_uid) = details.uid
354                            && passwd_entry.uid != expected_uid
355                        {
356                            mod_uid = Some(expected_uid);
357                        }
358
359                        if let Some(expected_shell) = &details.shell
360                            && passwd_entry.shell != *expected_shell
361                        {
362                            mod_shell = Some(expected_shell.clone());
363                        }
364
365                        if let Some(expected_home) = &details.home
366                            && passwd_entry.home != *expected_home
367                        {
368                            mod_home = Some(expected_home.clone());
369                        }
370
371                        if let Some(expected_comment) = &details.comment
372                            && passwd_entry.comment != *expected_comment
373                        {
374                            mod_comment = Some(expected_comment.clone());
375                        }
376
377                        if let Some(expected_group) = &details.group {
378                            let current_group =
379                                match get_primary_group(host_handler, &self.name).await {
380                                    Ok(g) => g,
381                                    Err(e) => return Err(RegentError::FailedDryRunEvaluation(e)),
382                                };
383                            if &current_group != expected_group {
384                                mod_group = Some(expected_group.clone());
385                            }
386                        }
387
388                        if let Some(expected_groups) = &details.groups {
389                            let current_supp_groups =
390                                match get_supplementary_groups(host_handler, &self.name).await {
391                                    Ok(g) => g,
392                                    Err(e) => return Err(RegentError::FailedDryRunEvaluation(e)),
393                                };
394
395                            let groups_compliant = if append {
396                                expected_groups
397                                    .iter()
398                                    .all(|g| current_supp_groups.contains(g))
399                            } else {
400                                let mut expected_sorted = expected_groups.clone();
401                                let mut current_sorted = current_supp_groups.clone();
402                                expected_sorted.sort();
403                                current_sorted.sort();
404                                expected_sorted == current_sorted
405                            };
406
407                            if !groups_compliant {
408                                if append {
409                                    let missing_groups: Vec<String> = expected_groups
410                                        .iter()
411                                        .filter(|g| !current_supp_groups.contains(g))
412                                        .cloned()
413                                        .collect();
414                                    if !missing_groups.is_empty() {
415                                        mod_groups = Some(missing_groups);
416                                    }
417                                } else {
418                                    mod_groups = Some(expected_groups.clone());
419                                }
420                            }
421                        }
422
423                        let needs_non_password_modification = mod_uid.is_some()
424                            || mod_group.is_some()
425                            || mod_groups.is_some()
426                            || mod_shell.is_some()
427                            || mod_home.is_some()
428                            || mod_comment.is_some();
429
430                        // Check if password needs modification (can't verify current password, so only if specified)
431                        let password_needs_modification = details.password.is_some();
432
433                        if needs_non_password_modification || password_needs_modification {
434                            // If both password and other properties need modification, include both
435                            // If only other properties need modification, don't include password
436                            // If only password needs modification, only include password
437                            let mod_password = if password_needs_modification {
438                                details.password.clone()
439                            } else {
440                                None
441                            };
442
443                            return Ok(AttributeComplianceAssessment::NonCompliant(
444                                RemediationsList::from(vec![Remediation::User(UserApiCall::from(
445                                    UserModuleInternalApiCall::Modify {
446                                        username: self.name.clone(),
447                                        uid: mod_uid,
448                                        group: mod_group,
449                                        groups: mod_groups,
450                                        append,
451                                        shell: mod_shell,
452                                        home: mod_home,
453                                        comment: mod_comment,
454                                        password: mod_password,
455                                    },
456                                    privilege.clone(),
457                                ))])
458                                .unwrap(),
459                            ));
460                        }
461
462                        Ok(AttributeComplianceAssessment::Compliant)
463                    }
464                    OsKind::MacOs(_) | OsKind::FreeBsd(_) | OsKind::Unknown => {
465                        Err(RegentError::FailedDryRunEvaluation(format!(
466                            "Detailed user management is not supported on {:?}",
467                            os_kind
468                        )))
469                    }
470                }
471            }
472        }
473    }
474}
475
476#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
477#[serde(rename_all = "PascalCase")]
478pub enum UserModuleInternalApiCall {
479    Add {
480        username: String,
481        uid: Option<u32>,
482        group: Option<String>,
483        groups: Option<Vec<String>>,
484        shell: Option<String>,
485        home: Option<String>,
486        comment: Option<String>,
487        password: Option<String>,
488        system: bool,
489        create_home: bool,
490    },
491    Modify {
492        username: String,
493        uid: Option<u32>,
494        group: Option<String>,
495        groups: Option<Vec<String>>,
496        append: bool,
497        shell: Option<String>,
498        home: Option<String>,
499        comment: Option<String>,
500        password: Option<String>,
501    },
502    Delete {
503        username: String,
504        remove_home: bool,
505    },
506}
507
508impl std::fmt::Display for UserModuleInternalApiCall {
509    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
510        match self {
511            UserModuleInternalApiCall::Add { username, .. } => {
512                write!(f, "add user {}", username)
513            }
514            UserModuleInternalApiCall::Modify { username, .. } => {
515                write!(f, "modify user {}", username)
516            }
517            UserModuleInternalApiCall::Delete { username, .. } => {
518                write!(f, "delete user {}", username)
519            }
520        }
521    }
522}
523
524#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
525pub struct UserApiCall {
526    pub api_call: UserModuleInternalApiCall,
527    privilege: Privilege,
528}
529
530impl UserApiCall {
531    pub fn display(&self) -> String {
532        match &self.api_call {
533            UserModuleInternalApiCall::Add { username, .. } => format!("Add user {}", username),
534            UserModuleInternalApiCall::Modify { username, .. } => {
535                format!("Modify user {}", username)
536            }
537            UserModuleInternalApiCall::Delete { username, .. } => {
538                format!("Delete user {}", username)
539            }
540        }
541    }
542
543    fn from(api_call: UserModuleInternalApiCall, privilege: Privilege) -> UserApiCall {
544        UserApiCall {
545            api_call,
546            privilege,
547        }
548    }
549}
550
551impl Check for UserApiCall {
552    fn check(&self) -> Result<(), RegentError> {
553        Ok(())
554    }
555
556    fn check_host_compatibility(
557        &self,
558        host_properties: &HostProperties,
559    ) -> Result<(), RegentError> {
560        match host_properties.os_kind() {
561            OsKind::Linux(_) => Ok(()),
562            #[cfg(feature = "windows")]
563            OsKind::Windows(_) => Ok(()),
564            incompatible_os_kind => Err(RegentError::IncompatibleHost(format!(
565                "Host is {:?} but user management is only supported on Linux or Windows (with windows feature)",
566                incompatible_os_kind
567            ))),
568        }
569    }
570}
571
572impl<Handler: HostHandler> ReachCompliance<Handler> for UserApiCall {
573    async fn call(
574        &self,
575        host_handler: &mut Handler,
576        host_properties: &Option<HostProperties>,
577        _optional_secret_provider: &Option<SecretProvidersPool>,
578    ) -> Result<InternalApiCallOutcome, RegentError> {
579        if let Some(props) = host_properties {
580            self.check_host_compatibility(props)?;
581        }
582
583        // Determine the effective OS kind - assume Linux if HostProperties is None
584        let os_kind = host_properties
585            .as_ref()
586            .map(|props| props.os_kind())
587            .unwrap_or(&OsKind::Linux(LinuxSpecifics {
588                linux_flavor: LinuxFlavor::Debian,
589                init_system: InitSystem::Systemd,
590            }));
591
592        match os_kind {
593            #[cfg(feature = "windows")]
594            OsKind::Windows(_) => {
595                let cmd_result = match &self.api_call {
596                    UserModuleInternalApiCall::Add {
597                        username,
598                        password,
599                        comment,
600                        ..
601                    } => {
602                        // net user username password /add /comment:"comment"
603                        let mut cmd = format!("net user {} /add", username);
604
605                        if let Some(pass) = password {
606                            // Note: This sets a plaintext password - in real usage, this should be handled securely
607                            cmd.push_str(&format!(" {}", pass));
608                        }
609
610                        if let Some(comm) = comment {
611                            cmd.push_str(&format!(" /comment:\"{}\"", comm));
612                        }
613
614                        host_handler.run_windows_command(&cmd).await
615                    }
616                    UserModuleInternalApiCall::Modify {
617                        username,
618                        comment,
619                        password,
620                        ..
621                    } => {
622                        let mut cmd = format!("net user {}", username);
623
624                        if let Some(_pass) = password {
625                            cmd.push_str(" * /password:req"); // This prompts for password change
626                        }
627
628                        if let Some(comm) = comment {
629                            cmd.push_str(&format!(" /comment:\"{}\"", comm));
630                        }
631
632                        host_handler.run_windows_command(&cmd).await
633                    }
634                    UserModuleInternalApiCall::Delete { username, .. } => {
635                        let cmd = format!("net user {} /delete", username);
636                        host_handler.run_windows_command(&cmd).await
637                    }
638                };
639
640                match cmd_result {
641                    Ok(result) => {
642                        if result.return_code == 0 {
643                            Ok(InternalApiCallOutcome::Success(None))
644                        } else {
645                            Ok(InternalApiCallOutcome::Failure(format!(
646                                "RC: {}, STDOUT: {}, STDERR: {}",
647                                result.return_code, result.stdout, result.stderr
648                            )))
649                        }
650                    }
651                    Err(e) => Ok(InternalApiCallOutcome::Failure(format!(
652                        "Command execution failed: {:?}",
653                        e
654                    ))),
655                }
656            }
657            OsKind::Linux(_) => {
658                let (cmd, privilege) = match &self.api_call {
659                    UserModuleInternalApiCall::Add {
660                        username,
661                        uid,
662                        group,
663                        groups,
664                        shell,
665                        home,
666                        comment,
667                        password,
668                        system,
669                        create_home,
670                    } => {
671                        let mut args: Vec<String> = Vec::new();
672                        if let Some(u) = uid {
673                            args.push(format!("-u {}", u));
674                        }
675                        if let Some(g) = group {
676                            args.push(format!("-g {}", g));
677                        }
678                        if let Some(gs) = groups
679                            && !gs.is_empty()
680                        {
681                            args.push(format!("-G {}", gs.join(",")));
682                        }
683                        if let Some(s) = shell {
684                            args.push(format!("-s {}", s));
685                        }
686                        if let Some(h) = home {
687                            args.push(format!("-d {}", h));
688                        }
689                        if let Some(c) = comment {
690                            args.push(format!("-c '{}'", c));
691                        }
692                        if let Some(p) = password {
693                            args.push(format!("-p '{}'", p));
694                        }
695                        if *system {
696                            args.push("-r".to_string());
697                        }
698                        if *create_home {
699                            args.push("-m".to_string());
700                        } else {
701                            args.push("-M".to_string());
702                        }
703                        (
704                            format!("useradd {} {}", args.join(" "), username),
705                            &self.privilege,
706                        )
707                    }
708                    UserModuleInternalApiCall::Modify {
709                        username,
710                        uid,
711                        group,
712                        groups,
713                        append,
714                        shell,
715                        home,
716                        comment,
717                        password,
718                    } => {
719                        let mut args: Vec<String> = Vec::new();
720                        if let Some(u) = uid {
721                            args.push(format!("-u {}", u));
722                        }
723                        if let Some(g) = group {
724                            args.push(format!("-g {}", g));
725                        }
726                        if let Some(gs) = groups {
727                            // Quote the value to handle empty list (removes all supplementary groups)
728                            args.push(format!("-G \"{}\"", gs.join(",")));
729                            if *append && !gs.is_empty() {
730                                args.push("-a".to_string());
731                            }
732                        }
733                        if let Some(s) = shell {
734                            args.push(format!("-s {}", s));
735                        }
736                        if let Some(h) = home {
737                            args.push(format!("-d {}", h));
738                        }
739                        if let Some(c) = comment {
740                            args.push(format!("-c '{}'", c));
741                        }
742                        if let Some(p) = password {
743                            args.push(format!("-p '{}'", p));
744                        }
745                        (
746                            format!("usermod {} {}", args.join(" "), username),
747                            &self.privilege,
748                        )
749                    }
750                    UserModuleInternalApiCall::Delete {
751                        username,
752                        remove_home,
753                    } => {
754                        let flags = if *remove_home { "-r " } else { "" };
755                        (format!("userdel {}{}", flags, username), &self.privilege)
756                    }
757                };
758
759                let cmd_result = host_handler.run_command(cmd.as_str(), privilege).await;
760
761                match cmd_result {
762                    Ok(result) => {
763                        if result.return_code == 0 {
764                            Ok(InternalApiCallOutcome::Success(None))
765                        } else {
766                            Ok(InternalApiCallOutcome::Failure(format!(
767                                "RC: {}, STDOUT: {}, STDERR: {}",
768                                result.return_code, result.stdout, result.stderr
769                            )))
770                        }
771                    }
772                    Err(e) => Ok(InternalApiCallOutcome::Failure(format!(
773                        "Command execution failed: {:?}",
774                        e
775                    ))),
776                }
777            }
778            OsKind::MacOs(_) | OsKind::FreeBsd(_) | OsKind::Unknown => {
779                Err(RegentError::FailedDryRunEvaluation(format!(
780                    "User management is not supported on {:?}",
781                    os_kind
782                )))
783            }
784        }
785    }
786}
787
788struct PasswdEntry {
789    uid: u32,
790    comment: String,
791    home: String,
792    shell: String,
793}
794
795async fn user_exists<Handler: HostHandler>(
796    host_handler: &mut Handler,
797    username: &str,
798) -> Result<bool, String> {
799    match host_handler
800        .run_command(&format!("id {}", username), &Privilege::None)
801        .await
802    {
803        Ok(result) => Ok(result.return_code == 0),
804        Err(e) => Err(format!("Unable to check if user exists: {:?}", e)),
805    }
806}
807
808#[cfg(feature = "windows")]
809async fn windows_user_exists<Handler: HostHandler>(
810    host_handler: &mut Handler,
811    username: &str,
812) -> Result<bool, String> {
813    match host_handler
814        .run_windows_command(&format!("net user {}", username))
815        .await
816    {
817        Ok(result) => {
818            // net user returns 0 for success (user exists), non-zero if user doesn't exist
819            // But it also returns non-zero for other errors, so we need to check the output
820            if result.return_code == 0 {
821                Ok(true)
822            } else if result.stderr.contains("not found") || result.stdout.contains("not found") {
823                Ok(false)
824            } else {
825                // Could be an error, but we'll assume user doesn't exist
826                Ok(false)
827            }
828        }
829        Err(e) => Err(format!(
830            "Unable to check if user exists on Windows: {:?}",
831            e
832        )),
833    }
834}
835
836async fn get_passwd_entry<Handler: HostHandler>(
837    host_handler: &mut Handler,
838    username: &str,
839) -> Result<PasswdEntry, String> {
840    match host_handler
841        .run_command(&format!("getent passwd {}", username), &Privilege::None)
842        .await
843    {
844        Ok(result) => {
845            if result.return_code != 0 {
846                return Err(format!("getent passwd failed for user {}", username));
847            }
848            // Format: username:x:uid:gid:comment:home:shell
849            let fields: Vec<&str> = result.stdout.trim().splitn(7, ':').collect();
850            if fields.len() < 7 {
851                return Err(format!(
852                    "Unexpected getent passwd output for {}: {}",
853                    username, result.stdout
854                ));
855            }
856            let uid = fields[2]
857                .parse::<u32>()
858                .map_err(|e| format!("Invalid UID '{}': {}", fields[2], e))?;
859            Ok(PasswdEntry {
860                uid,
861                comment: fields[4].to_string(),
862                home: fields[5].to_string(),
863                shell: fields[6].to_string(),
864            })
865        }
866        Err(e) => Err(format!(
867            "Unable to get passwd entry for {}: {:?}",
868            username, e
869        )),
870    }
871}
872
873async fn get_primary_group<Handler: HostHandler>(
874    host_handler: &mut Handler,
875    username: &str,
876) -> Result<String, String> {
877    match host_handler
878        .run_command(&format!("id -gn {}", username), &Privilege::None)
879        .await
880    {
881        Ok(result) => {
882            if result.return_code != 0 {
883                return Err(format!("id -gn failed for user {}", username));
884            }
885            Ok(result.stdout.trim().to_string())
886        }
887        Err(e) => Err(format!(
888            "Unable to get primary group for {}: {:?}",
889            username, e
890        )),
891    }
892}
893
894async fn get_supplementary_groups<Handler: HostHandler>(
895    host_handler: &mut Handler,
896    username: &str,
897) -> Result<Vec<String>, String> {
898    let primary_group = get_primary_group(host_handler, username).await?;
899
900    match host_handler
901        .run_command(&format!("id -Gn {}", username), &Privilege::None)
902        .await
903    {
904        Ok(result) => {
905            if result.return_code != 0 {
906                return Err(format!("id -Gn failed for user {}", username));
907            }
908            // id -Gn returns all groups (primary + supplementary); subtract primary
909            Ok(result
910                .stdout
911                .split_whitespace()
912                .map(|s| s.to_string())
913                .filter(|g| g != &primary_group)
914                .collect())
915        }
916        Err(e) => Err(format!(
917            "Unable to get supplementary groups for {}: {:?}",
918            username, e
919        )),
920    }
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926
927    #[test]
928    fn parsing_user_module_block_from_yaml_str() {
929        let raw_attributes = "---
930- Name: alice
931  State: !Present
932    Details:
933      Shell: /bin/bash
934      Comment: Alice Smith
935      Groups:
936        - sudo
937        - docker
938
939- Name: bob
940  State: !Absent
941        ";
942
943        let _attributes: Vec<UserExpectedState> = yaml_serde::from_str(raw_attributes).unwrap();
944    }
945
946    #[test]
947    fn yaml_and_rusty_api_matching() {
948        let raw_yaml_attribute = "---
949Name: alice
950State: !Present
951  Details:
952    Shell: /bin/bash
953    Comment: Alice Smith
954    Groups:
955      - sudo
956      - docker
957";
958
959        let yaml_defined: UserExpectedState = yaml_serde::from_str(raw_yaml_attribute).unwrap();
960
961        let rusty_defined = UserExpectedState::present_with_details(
962            "alice",
963            UserDetails::default()
964                .with_shell("/bin/bash")
965                .with_comment("Alice Smith")
966                .with_groups(vec!["sudo".to_string(), "docker".to_string()])
967                .finish(),
968        );
969
970        assert_eq!(yaml_defined, rusty_defined);
971    }
972
973    #[test]
974    fn absent_constructors_match_explicit_form() {
975        let mut any = UserExpectedState::present_with_defaults("placeholder");
976        assert_eq!(
977            UserExpectedState::absent_keep_home("bob"),
978            any.absent("bob", Some(false))
979        );
980        assert_eq!(
981            UserExpectedState::absent_remove_home("bob"),
982            any.absent("bob", Some(true))
983        );
984    }
985}