kanidm_unix_common/
unix_passwd.rs

1use serde::{Deserialize, Serialize};
2use serde_with::formats::CommaSeparator;
3use serde_with::{serde_as, DefaultOnNull, StringWithSeparator};
4use std::fmt;
5use std::fs::File;
6use std::io::{BufRead, Read};
7use std::path::Path;
8use std::str::FromStr;
9
10#[derive(Serialize, Deserialize, Debug, Default, Clone)]
11pub struct EtcDb {
12    pub users: Vec<EtcUser>,
13    pub shadow: Vec<EtcShadow>,
14    pub groups: Vec<EtcGroup>,
15}
16
17#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
18pub struct EtcUser {
19    pub name: String,
20    pub password: String,
21    pub uid: u32,
22    pub gid: u32,
23    pub gecos: String,
24    pub homedir: String,
25    pub shell: String,
26}
27
28pub fn parse_etc_passwd(bytes: &[u8]) -> Result<Vec<EtcUser>, UnixIntegrationError> {
29    use csv::ReaderBuilder;
30
31    let filecontents = strip_comments(bytes);
32
33    let mut rdr = ReaderBuilder::new()
34        .has_headers(false)
35        .delimiter(b':')
36        .from_reader(filecontents.as_bytes());
37
38    rdr.deserialize()
39        .map(|result| result.map_err(|_e| UnixIntegrationError))
40        .collect::<Result<Vec<EtcUser>, UnixIntegrationError>>()
41}
42
43pub fn read_etc_passwd_file<P: AsRef<Path>>(path: P) -> Result<Vec<EtcUser>, UnixIntegrationError> {
44    let mut file = File::open(path.as_ref()).map_err(|_| UnixIntegrationError)?;
45
46    let mut contents = vec![];
47    file.read_to_end(&mut contents)
48        .map_err(|_| UnixIntegrationError)?;
49
50    parse_etc_passwd(contents.as_slice()).map_err(|_| UnixIntegrationError)
51}
52
53#[derive(PartialEq, Default, Clone)]
54pub enum CryptPw {
55    Sha256(String),
56    Sha512(String),
57    #[default]
58    Invalid,
59}
60
61impl fmt::Display for CryptPw {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            CryptPw::Invalid => write!(f, "x"),
65            CryptPw::Sha256(s) | CryptPw::Sha512(s) => write!(f, "{}", s),
66        }
67    }
68}
69
70impl fmt::Debug for CryptPw {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        match self {
73            CryptPw::Invalid => write!(f, "x"),
74            CryptPw::Sha256(_s) => write!(f, "crypt sha256"),
75            CryptPw::Sha512(_s) => write!(f, "crypt sha512"),
76        }
77    }
78}
79
80impl FromStr for CryptPw {
81    type Err = &'static str;
82
83    fn from_str(value: &str) -> Result<Self, Self::Err> {
84        if value.starts_with("$6$") {
85            Ok(CryptPw::Sha512(value.to_string()))
86        } else if value.starts_with("$5$") {
87            Ok(CryptPw::Sha256(value.to_string()))
88        } else {
89            Ok(CryptPw::Invalid)
90        }
91    }
92}
93
94impl CryptPw {
95    pub fn is_valid(&self) -> bool {
96        !matches!(self, CryptPw::Invalid)
97    }
98
99    pub fn check_pw(&self, cred: &str) -> bool {
100        match &self {
101            CryptPw::Sha256(crypt) => sha_crypt::sha256_check(cred, crypt.as_str()).is_ok(),
102            CryptPw::Sha512(crypt) => sha_crypt::sha512_check(cred, crypt.as_str()).is_ok(),
103            CryptPw::Invalid => false,
104        }
105    }
106}
107
108#[serde_as]
109#[derive(Serialize, Deserialize, Debug, PartialEq, Default, Clone)]
110pub struct EtcShadow {
111    pub name: String,
112    #[serde_as(as = "serde_with::DisplayFromStr")]
113    pub password: CryptPw,
114    // 0 means must change next login.
115    // None means all other aging features are disabled
116    pub epoch_change_days: Option<i64>,
117    // 0 means no age
118    #[serde_as(deserialize_as = "DefaultOnNull")]
119    pub days_min_password_age: i64,
120    pub days_max_password_age: Option<i64>,
121    // 0 means no warning
122    #[serde_as(deserialize_as = "DefaultOnNull")]
123    pub days_warning_period: i64,
124    // Number of days after max_password_age passes where the password can
125    // still be accepted such that the user can update their password
126    pub days_inactivity_period: Option<i64>,
127    pub epoch_expire_date: Option<i64>,
128    pub flag_reserved: Option<u32>,
129}
130
131pub fn parse_etc_shadow(bytes: &[u8]) -> Result<Vec<EtcShadow>, UnixIntegrationError> {
132    use csv::ReaderBuilder;
133
134    let filecontents = strip_comments(bytes);
135
136    let mut rdr = ReaderBuilder::new()
137        .has_headers(false)
138        .delimiter(b':')
139        .from_reader(filecontents.as_bytes());
140    rdr.deserialize()
141        .map(|result| {
142            result.map_err(|err| {
143                eprintln!("{:?}", err);
144                UnixIntegrationError
145            })
146        })
147        .collect::<Result<Vec<EtcShadow>, UnixIntegrationError>>()
148}
149
150pub fn read_etc_shadow_file<P: AsRef<Path>>(
151    path: P,
152) -> Result<Vec<EtcShadow>, UnixIntegrationError> {
153    let mut file = File::open(path.as_ref()).map_err(|_| UnixIntegrationError)?;
154
155    let mut contents = vec![];
156    file.read_to_end(&mut contents)
157        .map_err(|_| UnixIntegrationError)?;
158
159    parse_etc_shadow(contents.as_slice()).map_err(|_| UnixIntegrationError)
160}
161
162#[serde_as]
163#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
164pub struct EtcGroup {
165    pub name: String,
166    pub password: String,
167    pub gid: u32,
168    #[serde_as(as = "StringWithSeparator::<CommaSeparator, String>")]
169    pub members: Vec<String>,
170}
171
172#[derive(Debug)]
173pub struct UnixIntegrationError;
174
175pub fn parse_etc_group(bytes: &[u8]) -> Result<Vec<EtcGroup>, UnixIntegrationError> {
176    use csv::ReaderBuilder;
177
178    let filecontents = strip_comments(bytes);
179
180    let mut rdr = ReaderBuilder::new()
181        .has_headers(false)
182        .delimiter(b':')
183        .from_reader(filecontents.as_bytes());
184    rdr.deserialize()
185        .map(|result| result.map_err(|_e| UnixIntegrationError))
186        .collect::<Result<Vec<EtcGroup>, UnixIntegrationError>>()
187}
188
189pub fn read_etc_group_file<P: AsRef<Path>>(path: P) -> Result<Vec<EtcGroup>, UnixIntegrationError> {
190    let mut file = File::open(path.as_ref()).map_err(|_| UnixIntegrationError)?;
191
192    let mut contents = vec![];
193    file.read_to_end(&mut contents)
194        .map_err(|_| UnixIntegrationError)?;
195
196    parse_etc_group(contents.as_slice()).map_err(|_| UnixIntegrationError)
197}
198
199fn strip_comments(bytes: &[u8]) -> String {
200    bytes
201        .lines()
202        .filter_map(|line| {
203            let line = line.ok()?;
204            let trimmed = line.trim();
205            if !trimmed.is_empty() && !trimmed.starts_with('#') {
206                Some(line)
207            } else {
208                None
209            }
210        })
211        .collect::<Vec<_>>()
212        .join("\n")
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    const EXAMPLE_PASSWD: &str = r#"root:x:0:0:root:/root:/bin/bash
220systemd-timesync:x:498:498:systemd Time Synchronization:/:/usr/sbin/nologin
221nobody:x:65534:65534:nobody:/var/lib/nobody:/bin/bash
222"#;
223
224    #[test]
225    fn test_parse_passwd() {
226        let users =
227            parse_etc_passwd(EXAMPLE_PASSWD.as_bytes()).expect("Failed to parse passwd data");
228
229        assert_eq!(
230            users[0],
231            EtcUser {
232                name: "root".to_string(),
233                password: "x".to_string(),
234                uid: 0,
235                gid: 0,
236                gecos: "root".to_string(),
237                homedir: "/root".to_string(),
238                shell: "/bin/bash".to_string(),
239            }
240        );
241
242        assert_eq!(
243            users[1],
244            EtcUser {
245                name: "systemd-timesync".to_string(),
246                password: "x".to_string(),
247                uid: 498,
248                gid: 498,
249                gecos: "systemd Time Synchronization".to_string(),
250                homedir: "/".to_string(),
251                shell: "/usr/sbin/nologin".to_string(),
252            }
253        );
254
255        assert_eq!(
256            users[2],
257            EtcUser {
258                name: "nobody".to_string(),
259                password: "x".to_string(),
260                uid: 65534,
261                gid: 65534,
262                gecos: "nobody".to_string(),
263                homedir: "/var/lib/nobody".to_string(),
264                shell: "/bin/bash".to_string(),
265            }
266        );
267    }
268
269    // IMPORTANT this is the password "a". Very secure, totes secret.
270    const EXAMPLE_SHADOW: &str = r#"sshd:!:19978::::::
271tss:!:19980::::::
272admin:$6$5.bXZTIXuVv.xI3.$sAubscCJPwnBWwaLt2JR33lo539UyiDku.aH5WVSX0Tct9nGL2ePMEmrqT3POEdBlgNQ12HJBwskewGu2dpF//:19980:0:99999:7:::
273"#;
274
275    #[test]
276    fn test_parse_shadow() {
277        let shadow =
278            parse_etc_shadow(EXAMPLE_SHADOW.as_bytes()).expect("Failed to parse passwd data");
279
280        assert_eq!(
281            shadow[0],
282            EtcShadow {
283                name: "sshd".to_string(),
284                password: CryptPw::Invalid,
285                epoch_change_days: Some(19978),
286                days_min_password_age: 0,
287                days_max_password_age: None,
288                days_warning_period: 0,
289                days_inactivity_period: None,
290                epoch_expire_date: None,
291                flag_reserved: None
292            }
293        );
294
295        assert_eq!(
296            shadow[1],
297            EtcShadow {
298                name: "tss".to_string(),
299                password: CryptPw::Invalid,
300                epoch_change_days: Some(19980),
301                days_min_password_age: 0,
302                days_max_password_age: None,
303                days_warning_period: 0,
304                days_inactivity_period: None,
305                epoch_expire_date: None,
306                flag_reserved: None
307            }
308        );
309
310        assert_eq!(shadow[2], EtcShadow {
311            name: "admin".to_string(),
312            password: CryptPw::Sha512("$6$5.bXZTIXuVv.xI3.$sAubscCJPwnBWwaLt2JR33lo539UyiDku.aH5WVSX0Tct9nGL2ePMEmrqT3POEdBlgNQ12HJBwskewGu2dpF//".to_string()),
313            epoch_change_days: Some(19980),
314            days_min_password_age: 0,
315            days_max_password_age: Some(99999),
316            days_warning_period: 7,
317            days_inactivity_period: None,
318            epoch_expire_date: None,
319            flag_reserved: None
320        });
321    }
322
323    const EXAMPLE_GROUP: &str = r#"root:x:0:
324wheel:x:481:admin,testuser
325"#;
326
327    #[test]
328    fn test_parse_group() {
329        let groups = parse_etc_group(EXAMPLE_GROUP.as_bytes()).expect("Failed to parse groups");
330
331        assert_eq!(
332            groups[0],
333            EtcGroup {
334                name: "root".to_string(),
335                password: "x".to_string(),
336                gid: 0,
337                members: vec![]
338            }
339        );
340
341        assert_eq!(
342            groups[1],
343            EtcGroup {
344                name: "wheel".to_string(),
345                password: "x".to_string(),
346                gid: 481,
347                members: vec!["admin".to_string(), "testuser".to_string(),]
348            }
349        );
350    }
351
352    #[test]
353    fn test_parse_group_freebsd() {
354        let group_data = r#"wheel:*:0:root,testuser,kanidm"#;
355        let groups = parse_etc_group(group_data.as_bytes()).expect("Failed to parse groups");
356        assert_eq!(
357            groups[0],
358            EtcGroup {
359                name: "wheel".to_string(),
360                password: "*".to_string(),
361                gid: 0,
362                members: vec![
363                    "root".to_string(),
364                    "testuser".to_string(),
365                    "kanidm".to_string()
366                ]
367            }
368        );
369        // empty group
370        let group_data = r#"
371        # $FreeBSD$
372# 
373wheel:*:0:"#;
374        let groups = parse_etc_group(group_data.as_bytes()).expect("Failed to parse groups");
375        assert_eq!(
376            groups[0],
377            EtcGroup {
378                name: "wheel".to_string(),
379                password: "*".to_string(),
380                gid: 0,
381                members: vec![]
382            }
383        );
384    }
385}