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