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 YesCrypt(String),
58 #[default]
59 Invalid,
60}
61
62impl fmt::Display for CryptPw {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 match self {
65 CryptPw::Invalid => write!(f, "x"),
66 CryptPw::Sha256(s) | CryptPw::Sha512(s) | CryptPw::YesCrypt(s) => write!(f, "{s}"),
67 }
68 }
69}
70
71impl fmt::Debug for CryptPw {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 match self {
74 CryptPw::Invalid => write!(f, "x"),
75 CryptPw::Sha256(_s) => write!(f, "crypt sha256"),
76 CryptPw::Sha512(_s) => write!(f, "crypt sha512"),
77 CryptPw::YesCrypt(_s) => write!(f, "crypt yescrypt"),
78 }
79 }
80}
81
82impl FromStr for CryptPw {
83 type Err = &'static str;
84
85 fn from_str(value: &str) -> Result<Self, Self::Err> {
86 if value.starts_with("$6$") {
87 Ok(CryptPw::Sha512(value.to_string()))
88 } else if value.starts_with("$5$") {
89 Ok(CryptPw::Sha256(value.to_string()))
90 } else if value.starts_with("$y$") {
91 Ok(CryptPw::YesCrypt(value.to_string()))
92 } else {
93 Ok(CryptPw::Invalid)
94 }
95 }
96}
97
98impl CryptPw {
99 pub fn is_valid(&self) -> bool {
100 !matches!(self, CryptPw::Invalid)
101 }
102
103 pub fn check_pw(&self, cred: &str) -> bool {
104 match &self {
105 CryptPw::Sha256(crypt) => sha_crypt::sha256_check(cred, crypt.as_str()).is_ok(),
106 CryptPw::Sha512(crypt) => sha_crypt::sha512_check(cred, crypt.as_str()).is_ok(),
107 CryptPw::YesCrypt(crypt) => {
108 use yescrypt::{PasswordHash, PasswordVerifier, Yescrypt};
109 let password_hash = match PasswordHash::new(crypt.as_str()) {
110 Ok(ph) => ph,
111 Err(err) => {
112 debug!("Failed to parse yescrypt password hash: {err:?}");
113 return false;
114 }
115 };
116 Yescrypt::default()
117 .verify_password(cred.as_bytes(), &password_hash)
118 .inspect_err(|err| debug!("Failed to verify password: {err:?}"))
119 .is_ok()
120 }
121 CryptPw::Invalid => false,
122 }
123 }
124}
125
126mod timestamp_days {
127 use serde::{Deserialize, Deserializer, Serialize, Serializer};
128 use time::OffsetDateTime;
129
130 pub fn serialize<S: Serializer>(
132 option: &Option<OffsetDateTime>,
133 serializer: S,
134 ) -> Result<S::Ok, S::Error> {
135 option
136 .map(|odt| {
137 let difference = odt - OffsetDateTime::UNIX_EPOCH;
138 difference.whole_days()
139 })
140 .serialize(serializer)
141 }
142
143 pub fn deserialize<'a, D: Deserializer<'a>>(
145 deserializer: D,
146 ) -> Result<Option<OffsetDateTime>, D::Error> {
147 Option::deserialize(deserializer)?
148 .map(|value| {
149 let difference = time::Duration::days(value);
150 Ok(OffsetDateTime::UNIX_EPOCH + difference)
151 })
152 .transpose()
153 }
154}
155
156#[serde_as]
157#[derive(Serialize, Deserialize, Debug, PartialEq, Default, Clone)]
158pub struct EtcShadow {
159 pub name: String,
160 #[serde_as(as = "serde_with::DisplayFromStr")]
161 pub password: CryptPw,
162 #[serde(with = "timestamp_days")]
165 pub epoch_change_seconds: Option<time::OffsetDateTime>,
166 #[serde_as(deserialize_as = "DefaultOnNull")]
168 pub days_min_password_age: i64,
169 pub days_max_password_age: Option<i64>,
170 #[serde_as(deserialize_as = "DefaultOnNull")]
172 pub days_warning_period: i64,
173 pub days_inactivity_period: Option<i64>,
176 #[serde(with = "timestamp_days")]
177 pub epoch_expire_seconds: Option<time::OffsetDateTime>,
178 pub flag_reserved: Option<u32>,
179}
180
181#[cfg(any(all(target_family = "unix", not(target_os = "freebsd")), test))]
182fn parse_linux_etc_shadow(bytes: &[u8]) -> Result<Vec<EtcShadow>, UnixIntegrationError> {
183 use csv::ReaderBuilder;
184
185 let filecontents = strip_comments(bytes);
186
187 let mut rdr = ReaderBuilder::new()
188 .has_headers(false)
189 .delimiter(b':')
190 .from_reader(filecontents.as_bytes());
191 rdr.deserialize()
192 .map(|result| {
193 result.map_err(|err| {
194 eprintln!("{err:?}");
195 UnixIntegrationError
196 })
197 })
198 .collect::<Result<Vec<EtcShadow>, UnixIntegrationError>>()
199}
200
201pub fn parse_etc_shadow(bytes: &[u8]) -> Result<Vec<EtcShadow>, UnixIntegrationError> {
202 #[cfg(all(target_family = "unix", not(target_os = "freebsd")))]
203 return parse_linux_etc_shadow(bytes);
204
205 #[cfg(all(target_family = "unix", target_os = "freebsd"))]
206 return parse_etc_master_passwd(bytes);
207}
208
209pub fn read_etc_shadow_file<P: AsRef<Path>>(
210 path: P,
211) -> Result<Vec<EtcShadow>, UnixIntegrationError> {
212 let mut file = File::open(path.as_ref()).map_err(|_| UnixIntegrationError)?;
213
214 let mut contents = vec![];
215 file.read_to_end(&mut contents)
216 .map_err(|_| UnixIntegrationError)?;
217
218 parse_etc_shadow(contents.as_slice()).map_err(|_| UnixIntegrationError)
219}
220
221#[serde_as]
222#[derive(Serialize, Deserialize, Debug, PartialEq, Default, Clone)]
223pub struct EtcMasterPasswd {
224 pub name: String,
225 #[serde_as(as = "serde_with::DisplayFromStr")]
226 pub password: CryptPw,
227 pub uid: u32,
228 pub gid: u32,
229 pub class: String,
230 #[serde(with = "time::serde::timestamp::option")]
231 pub epoch_change_seconds: Option<time::OffsetDateTime>,
232 #[serde(with = "time::serde::timestamp::option")]
233 pub epoch_expire_seconds: Option<time::OffsetDateTime>,
234 pub gecos: String,
235 pub homedir: String,
236 pub shell: String,
237}
238
239impl From<EtcMasterPasswd> for EtcShadow {
240 fn from(etc_master_passwd: EtcMasterPasswd) -> Self {
241 let EtcMasterPasswd {
242 name,
243 password,
244 epoch_change_seconds,
245 epoch_expire_seconds,
246 ..
247 } = etc_master_passwd;
248
249 let epoch_change_seconds = if epoch_change_seconds == Some(time::OffsetDateTime::UNIX_EPOCH)
250 {
251 None
252 } else {
253 epoch_change_seconds
254 };
255
256 let epoch_expire_seconds = if epoch_expire_seconds == Some(time::OffsetDateTime::UNIX_EPOCH)
257 {
258 None
259 } else {
260 epoch_expire_seconds
261 };
262
263 Self {
264 name,
265 password,
266 epoch_change_seconds,
267 epoch_expire_seconds,
268 ..Default::default()
269 }
270 }
271}
272
273#[cfg(any(all(target_family = "unix", target_os = "freebsd"), test))]
274fn parse_etc_master_passwd(bytes: &[u8]) -> Result<Vec<EtcShadow>, UnixIntegrationError> {
275 use csv::ReaderBuilder;
276
277 let filecontents = strip_comments(bytes);
278
279 let mut rdr = ReaderBuilder::new()
280 .has_headers(false)
281 .delimiter(b':')
282 .from_reader(filecontents.as_bytes());
283 let records = rdr
284 .deserialize()
285 .map(|result| {
286 result.map_err(|err| {
287 eprintln!("{err:?}");
288 UnixIntegrationError
289 })
290 })
291 .collect::<Result<Vec<EtcMasterPasswd>, UnixIntegrationError>>()?;
292
293 Ok(records.into_iter().map(EtcShadow::from).collect())
294}
295
296#[serde_as]
297#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
298pub struct EtcGroup {
299 pub name: String,
300 pub password: String,
301 pub gid: u32,
302 #[serde_as(as = "StringWithSeparator::<CommaSeparator, String>")]
303 pub members: Vec<String>,
304}
305
306#[derive(Debug)]
307pub struct UnixIntegrationError;
308
309pub fn parse_etc_group(bytes: &[u8]) -> Result<Vec<EtcGroup>, UnixIntegrationError> {
310 use csv::ReaderBuilder;
311
312 let filecontents = strip_comments(bytes);
313
314 let mut rdr = ReaderBuilder::new()
315 .has_headers(false)
316 .delimiter(b':')
317 .from_reader(filecontents.as_bytes());
318 rdr.deserialize()
319 .map(|result| result.map_err(|_e| UnixIntegrationError))
320 .collect::<Result<Vec<EtcGroup>, UnixIntegrationError>>()
321}
322
323pub fn read_etc_group_file<P: AsRef<Path>>(path: P) -> Result<Vec<EtcGroup>, UnixIntegrationError> {
324 let mut file = File::open(path.as_ref()).map_err(|_| UnixIntegrationError)?;
325
326 let mut contents = vec![];
327 file.read_to_end(&mut contents)
328 .map_err(|_| UnixIntegrationError)?;
329
330 parse_etc_group(contents.as_slice()).map_err(|_| UnixIntegrationError)
331}
332
333fn strip_comments(bytes: &[u8]) -> String {
334 bytes
335 .lines()
336 .filter_map(|line| {
337 let line = line.ok()?;
338 let trimmed = line.trim();
339 if !trimmed.is_empty() && !trimmed.starts_with('#') {
340 Some(line)
341 } else {
342 None
343 }
344 })
345 .collect::<Vec<_>>()
346 .join("\n")
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 const EXAMPLE_PASSWD: &str = r#"root:x:0:0:root:/root:/bin/bash
354systemd-timesync:x:498:498:systemd Time Synchronization:/:/usr/sbin/nologin
355nobody:x:65534:65534:nobody:/var/lib/nobody:/bin/bash
356"#;
357
358 #[test]
359 fn test_parse_passwd() {
360 let users =
361 parse_etc_passwd(EXAMPLE_PASSWD.as_bytes()).expect("Failed to parse passwd data");
362
363 assert_eq!(
364 users[0],
365 EtcUser {
366 name: "root".to_string(),
367 password: "x".to_string(),
368 uid: 0,
369 gid: 0,
370 gecos: "root".to_string(),
371 homedir: "/root".to_string(),
372 shell: "/bin/bash".to_string(),
373 }
374 );
375
376 assert_eq!(
377 users[1],
378 EtcUser {
379 name: "systemd-timesync".to_string(),
380 password: "x".to_string(),
381 uid: 498,
382 gid: 498,
383 gecos: "systemd Time Synchronization".to_string(),
384 homedir: "/".to_string(),
385 shell: "/usr/sbin/nologin".to_string(),
386 }
387 );
388
389 assert_eq!(
390 users[2],
391 EtcUser {
392 name: "nobody".to_string(),
393 password: "x".to_string(),
394 uid: 65534,
395 gid: 65534,
396 gecos: "nobody".to_string(),
397 homedir: "/var/lib/nobody".to_string(),
398 shell: "/bin/bash".to_string(),
399 }
400 );
401 }
402
403 const EXAMPLE_SHADOW: &str = r#"sshd:!:19978::::::
405tss:!:19980::::::
406admin:$6$5.bXZTIXuVv.xI3.$sAubscCJPwnBWwaLt2JR33lo539UyiDku.aH5WVSX0Tct9nGL2ePMEmrqT3POEdBlgNQ12HJBwskewGu2dpF//:19980:0:99999:7:::
407admin_yescrypt:$y$j9T$LdJMENpBABJJ3hIHjB1Bi.$GFxnbKnR8WaEdBMGMctf6JGMs56hU5dYcy6UrKGWr62:19980:0:99999:7:::
408"#;
409
410 #[test]
411 fn test_parse_shadow() {
412 let shadow =
413 parse_linux_etc_shadow(EXAMPLE_SHADOW.as_bytes()).expect("Failed to parse passwd data");
414
415 assert_eq!(
416 shadow[0],
417 EtcShadow {
418 name: "sshd".to_string(),
419 password: CryptPw::Invalid,
420 epoch_change_seconds: Some(
421 time::OffsetDateTime::UNIX_EPOCH + time::Duration::days(19978)
422 ),
423 days_min_password_age: 0,
424 days_max_password_age: None,
425 days_warning_period: 0,
426 days_inactivity_period: None,
427 epoch_expire_seconds: None,
428 flag_reserved: None
429 }
430 );
431
432 assert_eq!(
433 shadow[1],
434 EtcShadow {
435 name: "tss".to_string(),
436 password: CryptPw::Invalid,
437 epoch_change_seconds: Some(
438 time::OffsetDateTime::UNIX_EPOCH + time::Duration::days(19980)
439 ),
440 days_min_password_age: 0,
441 days_max_password_age: None,
442 days_warning_period: 0,
443 days_inactivity_period: None,
444 epoch_expire_seconds: None,
445 flag_reserved: None
446 }
447 );
448
449 assert_eq!(shadow[2], EtcShadow {
450 name: "admin".to_string(),
451 password: CryptPw::Sha512("$6$5.bXZTIXuVv.xI3.$sAubscCJPwnBWwaLt2JR33lo539UyiDku.aH5WVSX0Tct9nGL2ePMEmrqT3POEdBlgNQ12HJBwskewGu2dpF//".to_string()),
452 epoch_change_seconds: Some(time::OffsetDateTime::UNIX_EPOCH + time::Duration::days(19980)),
453 days_min_password_age: 0,
454 days_max_password_age: Some(99999),
455 days_warning_period: 7,
456 days_inactivity_period: None,
457 epoch_expire_seconds: None,
458 flag_reserved: None
459 });
460
461 assert_eq!(
462 shadow[3],
463 EtcShadow {
464 name: "admin_yescrypt".to_string(),
465 password: CryptPw::YesCrypt(
466 "$y$j9T$LdJMENpBABJJ3hIHjB1Bi.$GFxnbKnR8WaEdBMGMctf6JGMs56hU5dYcy6UrKGWr62"
467 .to_string()
468 ),
469 epoch_change_seconds: Some(
470 time::OffsetDateTime::UNIX_EPOCH + time::Duration::days(19980)
471 ),
472 days_min_password_age: 0,
473 days_max_password_age: Some(99999),
474 days_warning_period: 7,
475 days_inactivity_period: None,
476 epoch_expire_seconds: None,
477 flag_reserved: None
478 }
479 );
480 }
481
482 const EXAMPLE_GROUP: &str = r#"root:x:0:
483wheel:x:481:admin,testuser
484"#;
485
486 #[test]
487 fn test_parse_group() {
488 let groups = parse_etc_group(EXAMPLE_GROUP.as_bytes()).expect("Failed to parse groups");
489
490 assert_eq!(
491 groups[0],
492 EtcGroup {
493 name: "root".to_string(),
494 password: "x".to_string(),
495 gid: 0,
496 members: vec![]
497 }
498 );
499
500 assert_eq!(
501 groups[1],
502 EtcGroup {
503 name: "wheel".to_string(),
504 password: "x".to_string(),
505 gid: 481,
506 members: vec!["admin".to_string(), "testuser".to_string(),]
507 }
508 );
509 }
510
511 #[test]
512 fn test_parse_group_freebsd() {
513 let group_data = r#"wheel:*:0:root,testuser,kanidm"#;
514 let groups = parse_etc_group(group_data.as_bytes()).expect("Failed to parse groups");
515 assert_eq!(
516 groups[0],
517 EtcGroup {
518 name: "wheel".to_string(),
519 password: "*".to_string(),
520 gid: 0,
521 members: vec![
522 "root".to_string(),
523 "testuser".to_string(),
524 "kanidm".to_string()
525 ]
526 }
527 );
528 let group_data = r#"
530 # $FreeBSD$
531#
532wheel:*:0:"#;
533 let groups = parse_etc_group(group_data.as_bytes()).expect("Failed to parse groups");
534 assert_eq!(
535 groups[0],
536 EtcGroup {
537 name: "wheel".to_string(),
538 password: "*".to_string(),
539 gid: 0,
540 members: vec![]
541 }
542 );
543 }
544
545 #[test]
546 fn test_parse_passwd_freebsd() {
547 let passwd_data = r#" # Comment
548root:*:0:0:Charlie &:/root:/bin/sh
549toor:*:0:0:Bourne-again Superuser:/root:
550daemon:*:1:1:Owner of many system processes:/root:/usr/sbin/nologin
551"#;
552 let users = parse_etc_passwd(passwd_data.as_bytes()).expect("Failed to parse passwd data");
553
554 assert_eq!(
555 users[0],
556 EtcUser {
557 name: "root".to_string(),
558 password: "*".to_string(),
559 uid: 0,
560 gid: 0,
561 gecos: "Charlie &".to_string(),
562 homedir: "/root".to_string(),
563 shell: "/bin/sh".to_string(),
564 }
565 );
566
567 assert_eq!(
568 users[1],
569 EtcUser {
570 name: "toor".to_string(),
571 password: "*".to_string(),
572 uid: 0,
573 gid: 0,
574 gecos: "Bourne-again Superuser".to_string(),
575 homedir: "/root".to_string(),
576 shell: "".to_string(),
577 }
578 );
579
580 assert_eq!(
581 users[2],
582 EtcUser {
583 name: "daemon".to_string(),
584 password: "*".to_string(),
585 uid: 1,
586 gid: 1,
587 gecos: "Owner of many system processes".to_string(),
588 homedir: "/root".to_string(),
589 shell: "/usr/sbin/nologin".to_string(),
590 }
591 );
592 }
593
594 #[test]
595 fn test_parse_masterpasswd_freebsd() {
596 let master_passwd_data = r#"# $FreeBSD$
597root:$6$U7ePyqmS.jKiqDWG$EFhw5zmkjK1h02QJvefu5RuTryxIhqzUmcFjnofafd2abgHzYuvWdqpyCw/ZfNOSTUAMNiJUcwtCW8SOFwq/i/:0:0::0:0:Charlie &:/root:/bin/sh
598toor:*:0:0::0:0:Bourne-again Superuser:/root:
599daemon:*:1:1::0:0:Owner of many system processes:/root:/usr/sbin/nologin
600operator:*:2:5::0:0:System &:/:/usr/sbin/nologin
601bin:*:3:7::0:0:Binaries Commands and Source:/:/usr/sbin/nologin
602tty:*:4:65533::0:0:Tty Sandbox:/:/usr/sbin/nologin
603"#;
604
605 let shadow = parse_etc_master_passwd(master_passwd_data.as_bytes())
606 .expect("Failed to parse freebsd shadow");
607
608 assert_eq!(
609 shadow[0],
610 EtcShadow {
611 name: "root".to_string(),
612 password: CryptPw::Sha512("$6$U7ePyqmS.jKiqDWG$EFhw5zmkjK1h02QJvefu5RuTryxIhqzUmcFjnofafd2abgHzYuvWdqpyCw/ZfNOSTUAMNiJUcwtCW8SOFwq/i/".to_string()),
613 epoch_change_seconds: None,
614 days_min_password_age: 0,
615 days_max_password_age: None,
616 days_warning_period: 0,
617 days_inactivity_period: None,
618 epoch_expire_seconds: None,
619 flag_reserved: None
620 }
621 );
622
623 assert_eq!(
624 shadow[1],
625 EtcShadow {
626 name: "toor".to_string(),
627 password: CryptPw::Invalid,
628 epoch_change_seconds: None,
629 days_min_password_age: 0,
630 days_max_password_age: None,
631 days_warning_period: 0,
632 days_inactivity_period: None,
633 epoch_expire_seconds: None,
634 flag_reserved: None
635 }
636 );
637
638 assert_eq!(
639 shadow[2],
640 EtcShadow {
641 name: "daemon".to_string(),
642 password: CryptPw::Invalid,
643 epoch_change_seconds: None,
644 days_min_password_age: 0,
645 days_max_password_age: None,
646 days_warning_period: 0,
647 days_inactivity_period: None,
648 epoch_expire_seconds: None,
649 flag_reserved: None
650 }
651 );
652 }
653}