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