1pub mod nameservice;
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::io::{BufRead, BufReader};
8use std::num::ParseIntError;
9use std::path::PathBuf;
10use std::str::FromStr;
11
12use camino::Utf8Path;
13use cap_std_ext::dirext::{CapStdExtDirExt, CapStdExtDirExtUtf8};
14use cap_std_ext::{cap_std::fs::Dir, cap_std::fs_utf8::Dir as DirUtf8};
15use thiserror::Error;
16
17const SYSUSERSD: &str = "usr/lib/sysusers.d";
18
19#[derive(Debug, Error)]
21#[allow(missing_docs)]
22pub enum Error {
23 #[error("I/O error: {0}")]
24 Io(#[from] std::io::Error),
25 #[error("I/O error on {path}: {err}")]
26 PathIo { path: PathBuf, err: std::io::Error },
27 #[error("Failed to parse sysusers entry: {0}")]
28 ParseFailure(String),
29 #[error("Failed to parse sysusers entry from {path}: {err}")]
30 ParseFailureInFile { path: PathBuf, err: String },
31 #[error("Failed to load etc/passwd: {0}")]
32 PasswdLoadFailure(String),
33 #[error("Failed to load etc/group: {0}")]
34 GroupLoadFailure(String),
35}
36
37pub type Result<T> = std::result::Result<T, Error>;
39
40#[derive(Debug, PartialEq, Eq)]
42pub enum GroupReference {
43 Numeric(u32),
45 Name(String),
47 Path(String),
49}
50
51impl From<u32> for GroupReference {
52 fn from(value: u32) -> Self {
53 Self::Numeric(value)
54 }
55}
56
57impl FromStr for GroupReference {
58 type Err = ParseIntError;
59
60 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
61 let r = if s.starts_with('/') {
62 Self::Path(s.to_owned())
63 } else if s.chars().all(|c| c.is_ascii_digit()) {
64 Self::Numeric(u32::from_str(s)?)
65 } else {
66 Self::Name(s.to_owned())
67 };
68 Ok(r)
69 }
70}
71
72#[derive(Debug, PartialEq, Eq)]
74pub enum IdSource {
75 Numeric(u32),
77 Path(String),
79}
80
81impl FromStr for IdSource {
82 type Err = ParseIntError;
83
84 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
85 let r = if s.starts_with('/') {
86 Self::Path(s.to_owned())
87 } else {
88 Self::Numeric(u32::from_str(s)?)
89 };
90 Ok(r)
91 }
92}
93
94impl From<u32> for IdSource {
95 fn from(value: u32) -> Self {
96 Self::Numeric(value)
97 }
98}
99
100#[derive(Debug, PartialEq, Eq)]
102#[allow(missing_docs)]
103pub enum SysusersEntry {
104 User {
106 name: String,
107 uid: Option<IdSource>,
108 pgid: Option<GroupReference>,
109 gecos: String,
110 home: Option<String>,
111 shell: Option<String>,
112 },
113 Group { name: String, id: Option<IdSource> },
115 Range { start: u32, end: u32 },
117}
118
119impl SysusersEntry {
120 fn next_token(s: &str) -> Option<(&str, &str)> {
128 let s = s.trim_start();
129 if s.is_empty() {
130 return None;
131 }
132 let (first, rest) = match s.strip_prefix('"') {
133 None => match s.find(|c: char| c.is_whitespace()) {
134 Some(idx) => s.split_at(idx),
135 None => (s, ""),
136 },
137 Some(rest) => {
138 let end = rest.find('"')?;
139 (&rest[..end], &rest[end + 1..])
140 }
141 };
142 Some((first, rest))
143 }
144
145 fn next_token_owned(s: &str) -> Option<(String, &str)> {
146 Self::next_token(s)
147 .filter(|(token, _)| !token.is_empty())
148 .map(|(a, b)| (a.to_owned(), b))
149 }
150
151 fn next_optional_token(s: &str) -> Option<(Option<&str>, &str)> {
152 let (token, s) = Self::next_token(s)?;
153 let token = Some(token).filter(|t| !t.is_empty() && *t != "-");
154 Some((token, s))
155 }
156
157 fn next_optional_token_owned(s: &str) -> Option<(Option<String>, &str)> {
158 Self::next_optional_token(s).map(|(a, b)| (a.map(|v| v.to_owned()), b))
159 }
160
161 pub(crate) fn parse(s: &str) -> Result<Option<SysusersEntry>> {
162 let err = || Error::ParseFailure(s.to_owned());
163 let (ftype, s) = Self::next_token(s)
164 .filter(|(token, _)| !token.is_empty())
165 .ok_or_else(err)?;
166 let r = match ftype {
167 "u" | "u!" => {
168 let (name, s) = Self::next_token_owned(s).ok_or_else(err)?;
169 let (id, s) = Self::next_optional_token(s).unwrap_or_default();
170 let (uid, pgid) = id
171 .and_then(|v| v.split_once(':'))
172 .or_else(|| id.map(|id| (id, id)))
173 .map(|(uid, gid)| (Some(uid), Some(gid)))
174 .unwrap_or((None, None));
175 let uid = uid
176 .filter(|&v| v != "-")
177 .map(|id| id.parse())
178 .transpose()
179 .map_err(|_| err())?;
180 let pgid = pgid.map(|id| id.parse()).transpose().map_err(|_| err())?;
181 let (gecos, s) = Self::next_token(s).unwrap_or_default();
182 let gecos = gecos.to_owned();
183 let (home, s) = Self::next_optional_token_owned(s).unwrap_or_default();
184 let (shell, _) = Self::next_optional_token_owned(s).unwrap_or_default();
185 SysusersEntry::User {
186 name,
187 uid,
188 pgid,
189 gecos,
190 home,
191 shell,
192 }
193 }
194 "g" => {
195 let (name, s) = Self::next_token_owned(s).ok_or_else(err)?;
196 let (id, _) = Self::next_optional_token(s).unwrap_or_default();
197 let id = id.map(|id| id.parse()).transpose().map_err(|_| err())?;
198 SysusersEntry::Group { name, id }
199 }
200 "r" => {
201 let (_, s) = Self::next_optional_token(s).ok_or_else(err)?;
202 let (range, _) = Self::next_token(s).ok_or_else(err)?;
203 let (start, end) = range.split_once('-').ok_or_else(err)?;
204 let start: u32 = start.parse().map_err(|_| err())?;
205 let end: u32 = end.parse().map_err(|_| err())?;
206 SysusersEntry::Range { start, end }
207 }
208 _ => return Ok(None),
210 };
211 Ok(Some(r))
212 }
213}
214
215pub fn read_sysusers(rootfs: &Dir) -> Result<Vec<SysusersEntry>> {
218 let Some(d) = rootfs.open_dir_optional(SYSUSERSD)? else {
219 return Ok(Default::default());
220 };
221 let d = DirUtf8::from_cap_std(d);
222 let mut result = Vec::new();
223 let mut found_users = BTreeSet::new();
224 let mut found_groups = BTreeSet::new();
225 for name in d.filenames_sorted()? {
226 let Some("conf") = Utf8Path::new(&name).extension() else {
227 continue;
228 };
229 let r = d.open(&name).map(BufReader::new)?;
230 for line in r.lines() {
231 let line = line?;
232 if line.is_empty() || line.starts_with("#") {
233 continue;
234 }
235 let Some(e) = SysusersEntry::parse(&line).map_err(|e| Error::ParseFailureInFile {
236 path: name.clone().into(),
237 err: e.to_string(),
238 })?
239 else {
240 continue;
241 };
242 match e {
243 SysusersEntry::User {
244 ref name, ref pgid, ..
245 } if !found_users.contains(name.as_str()) => {
246 found_users.insert(name.clone());
247 found_groups.insert(name.clone());
248 let pgid = pgid.as_ref().and_then(|g| match g {
250 GroupReference::Numeric(n) => Some(IdSource::Numeric(*n)),
251 GroupReference::Path(p) => Some(IdSource::Path(p.clone())),
252 GroupReference::Name(_) => None,
253 });
254 result.push(SysusersEntry::Group {
255 name: name.clone(),
256 id: pgid,
257 });
258 result.push(e);
259 }
260 SysusersEntry::Group { ref name, .. } if !found_groups.contains(name.as_str()) => {
261 found_groups.insert(name.clone());
262 result.push(e);
263 }
264 _ => {
265 }
267 }
268 }
269 }
270 Ok(result)
271}
272
273#[derive(Debug, Default)]
275pub struct SysusersAnalysis {
276 pub missing_users: BTreeSet<String>,
278 pub missing_groups: BTreeSet<String>,
280}
281
282impl SysusersAnalysis {
283 pub fn is_empty(&self) -> bool {
285 self.missing_users.is_empty() && self.missing_groups.is_empty()
286 }
287}
288
289pub fn analyze(rootfs: &Dir) -> Result<SysusersAnalysis> {
291 struct SysuserData {
292 #[allow(dead_code)]
293 uid: Option<IdSource>,
294 #[allow(dead_code)]
295 pgid: Option<GroupReference>,
296 }
297
298 struct SysgroupData {
299 #[allow(dead_code)]
300 id: Option<IdSource>,
301 }
302
303 let Some(passwd) = nameservice::passwd::load_etc_passwd(rootfs)
304 .map_err(|e| Error::PasswdLoadFailure(e.to_string()))?
305 else {
306 return Ok(SysusersAnalysis::default());
308 };
309
310 let mut passwd = passwd
311 .into_iter()
312 .map(|mut e| {
313 let mut name = String::new();
315 std::mem::swap(&mut e.name, &mut name);
316 (name, e)
317 })
318 .collect::<BTreeMap<_, _>>();
319 let mut group = nameservice::group::load_etc_group(rootfs)
320 .map_err(|e| Error::GroupLoadFailure(e.to_string()))?
321 .into_iter()
322 .map(|mut e| {
323 let mut name = String::new();
325 std::mem::swap(&mut e.name, &mut name);
326 (name, e)
327 })
328 .collect::<BTreeMap<_, _>>();
329
330 let (sysusers_users, sysusers_groups) = {
331 let mut users = BTreeMap::new();
332 let mut groups = BTreeMap::new();
333 for ent in read_sysusers(rootfs)? {
334 match ent {
335 SysusersEntry::User {
336 name, uid, pgid, ..
337 } => {
338 users.insert(name, SysuserData { uid, pgid });
339 }
340 SysusersEntry::Group { name, id } => {
341 groups.insert(name, SysgroupData { id });
342 }
343 SysusersEntry::Range { .. } => {
344 }
346 }
347 }
348 (users, groups)
349 };
350
351 passwd.retain(|k, _| !sysusers_users.contains_key(k.as_str()));
352 group.retain(|k, _| !sysusers_groups.contains_key(k.as_str()));
353
354 Ok(SysusersAnalysis {
355 missing_users: passwd.into_keys().collect(),
356 missing_groups: group.into_keys().collect(),
357 })
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 use std::io::Write;
365
366 use anyhow::Result;
367 use cap_std_ext::cap_std;
368 use indoc::indoc;
369
370 const SYSUSERS_REF: &str = indoc::indoc! { r##"
371 # Comment here
372 u root 0 "Super User" /root /bin/bash
373 # This one omits the shell
374 u root 0 "Super User" /root
375 u bin 1:1 "bin" /bin -
376 # Another comment
377 u daemon 2:2 "daemon" /sbin -
378 u adm 3:4 "adm" /var/adm -
379 u lp 4:7 "lp" /var/spool/lpd -
380 u sync 5:0 "sync" /sbin /bin/sync
381 u shutdown 6:0 "shutdown" /sbin /sbin/shutdown
382 u halt 7:0 "halt" /sbin /sbin/halt
383 u mail 8:12 "mail" /var/spool/mail -
384 u operator 11:0 "operator" /root -
385 u games 12:100 "games" /usr/games -
386 u ftp 14:50 "FTP User" /var/ftp -
387 u nobody 65534:65534 "Kernel Overflow User" - -
388 # Newer systemd uses locked references
389 u! systemd-coredump - "systemd Core Dumper"
390 "##};
391
392 const SYSGROUPS_REF: &str = indoc::indoc! { r##"
393 # A comment here
394 g root 0
395 g bin 1
396 g daemon 2
397 g sys 3
398 g adm 4
399 g tty 5
400 g disk 6
401 g lp 7
402 g mem 8
403 g kmem 9
404 g wheel 10
405 g cdrom 11
406 g mail 12
407 g man 15
408 g dialout 18
409 g floppy 19
410 g games 20
411 g utmp 22
412 g tape 33
413 g kvm 36
414 g video 39
415 g ftp 50
416 g lock 54
417 g audio 63
418 g users 100
419 g clock 103
420 g input 104
421 g render 105
422 g sgx 106
423 g nobody 65534
424 "##};
425
426 const OTHER_SYSUSERS_REF: &str = indoc! { r#"
428 u qemu 107:qemu "qemu user" - -
429 u vboxadd -:1 - /var/run/vboxadd -
430 "#};
431
432 const OTHER_SYSUSERS_EXAMPLES: &str = indoc! { r#"
434 u user_name /file/owned/by/user "User Description" /home/dir /path/to/shell
435 g group_name /file/owned/by/group
436 # Note no GECOS field
437 u otheruser -
438 # And finally, no numeric specification at all
439 u justusername
440 g justgroupname
441 "#};
442
443 const OTHER_SYSUSERS_UNHANDLED: &str = indoc! { r#"
444 m user_name group_name
445 r - 42-43
446 "#};
447
448 fn parse_all(s: &str) -> impl Iterator<Item = SysusersEntry> + use<'_> {
449 s.lines()
450 .filter(|line| !(line.is_empty() || line.starts_with('#')))
451 .map(|line| SysusersEntry::parse(line).unwrap().unwrap())
452 }
453
454 #[test]
455 fn test_empty_quoted_fields() -> Result<()> {
456 for (line, uid, home, shell) in [
457 (
458 r#"u service 123 "" /home/service /bin/sh"#,
459 Some(123),
460 Some("/home/service"),
461 Some("/bin/sh"),
462 ),
463 (
464 r#"u service "" "" /home/service /bin/sh"#,
465 None,
466 Some("/home/service"),
467 Some("/bin/sh"),
468 ),
469 (
470 r#"u service 123 "" "" /bin/sh"#,
471 Some(123),
472 None,
473 Some("/bin/sh"),
474 ),
475 (r#"u service 123 "" "" """#, Some(123), None, None),
476 ] {
477 assert_eq!(
478 SysusersEntry::parse(line)?,
479 Some(SysusersEntry::User {
480 name: "service".into(),
481 uid: uid.map(IdSource::Numeric),
482 pgid: uid.map(GroupReference::Numeric),
483 gecos: String::new(),
484 home: home.map(str::to_owned),
485 shell: shell.map(str::to_owned),
486 }),
487 "{line}"
488 );
489 }
490 for line in [r#""" user"#, r#"u "" 123"#, r#"g "" 123"#] {
491 assert!(SysusersEntry::parse(line).is_err(), "{line}");
492 }
493 Ok(())
494 }
495
496 #[test]
497 fn test_sysusers_parse() -> Result<()> {
498 let mut entries = parse_all(SYSUSERS_REF);
499 assert_eq!(
500 entries.next().unwrap(),
501 SysusersEntry::User {
502 name: "root".into(),
503 uid: Some(0.into()),
504 pgid: Some(0.into()),
505 gecos: "Super User".into(),
506 home: Some("/root".into()),
507 shell: Some("/bin/bash".into())
508 }
509 );
510 assert_eq!(
511 entries.next().unwrap(),
512 SysusersEntry::User {
513 name: "root".into(),
514 uid: Some(0.into()),
515 pgid: Some(0.into()),
516 gecos: "Super User".into(),
517 home: Some("/root".into()),
518 shell: None
519 }
520 );
521 assert_eq!(
522 entries.next().unwrap(),
523 SysusersEntry::User {
524 name: "bin".into(),
525 uid: Some(1.into()),
526 pgid: Some(1.into()),
527 gecos: "bin".into(),
528 home: Some("/bin".into()),
529 shell: None
530 }
531 );
532 let _ = entries.next().unwrap();
533 assert_eq!(
534 entries.next().unwrap(),
535 SysusersEntry::User {
536 name: "adm".into(),
537 uid: Some(3.into()),
538 pgid: Some(4.into()),
539 gecos: "adm".into(),
540 home: Some("/var/adm".into()),
541 shell: None
542 }
543 );
544 assert_eq!(entries.count(), 10);
545
546 let mut entries = parse_all(OTHER_SYSUSERS_REF);
547 assert_eq!(
548 entries.next().unwrap(),
549 SysusersEntry::User {
550 name: "qemu".into(),
551 uid: Some(107.into()),
552 pgid: Some(GroupReference::Name("qemu".into())),
553 gecos: "qemu user".into(),
554 home: None,
555 shell: None
556 }
557 );
558 assert_eq!(
559 entries.next().unwrap(),
560 SysusersEntry::User {
561 name: "vboxadd".into(),
562 uid: None,
563 pgid: Some(1.into()),
564 gecos: "-".into(),
565 home: Some("/var/run/vboxadd".into()),
566 shell: None
567 }
568 );
569 assert_eq!(entries.count(), 0);
570
571 let mut entries = parse_all(OTHER_SYSUSERS_EXAMPLES);
572 assert_eq!(
573 entries.next().unwrap(),
574 SysusersEntry::User {
575 name: "user_name".into(),
576 uid: Some(IdSource::Path("/file/owned/by/user".into())),
577 pgid: Some(GroupReference::Path("/file/owned/by/user".into())),
578 gecos: "User Description".into(),
579 home: Some("/home/dir".into()),
580 shell: Some("/path/to/shell".into())
581 }
582 );
583 assert_eq!(
584 entries.next().unwrap(),
585 SysusersEntry::Group {
586 name: "group_name".into(),
587 id: Some(IdSource::Path("/file/owned/by/group".into()))
588 }
589 );
590 assert_eq!(
591 entries.next().unwrap(),
592 SysusersEntry::User {
593 name: "otheruser".into(),
594 uid: None,
595 pgid: None,
596 gecos: "".into(),
597 home: None,
598 shell: None
599 }
600 );
601 assert_eq!(
602 entries.next().unwrap(),
603 SysusersEntry::User {
604 name: "justusername".into(),
605 uid: None,
606 pgid: None,
607 gecos: "".into(),
608 home: None,
609 shell: None
610 }
611 );
612 assert_eq!(
613 entries.next().unwrap(),
614 SysusersEntry::Group {
615 name: "justgroupname".into(),
616 id: None
617 }
618 );
619 assert_eq!(entries.count(), 0);
620
621 let n = OTHER_SYSUSERS_UNHANDLED
622 .lines()
623 .filter(|line| !(line.is_empty() || line.starts_with('#')))
624 .try_fold(Vec::new(), |mut acc, line| {
625 if let Some(v) = SysusersEntry::parse(line)? {
626 acc.push(v);
627 }
628 anyhow::Ok(acc)
629 })?;
630 assert_eq!(n.len(), 1);
631 assert_eq!(n[0], SysusersEntry::Range { start: 42, end: 43 });
632
633 Ok(())
634 }
635
636 #[test]
637 fn test_sysgroups_parse() -> Result<()> {
638 let mut entries = SYSGROUPS_REF
639 .lines()
640 .filter(|line| !(line.is_empty() || line.starts_with('#')))
641 .map(|line| SysusersEntry::parse(line).unwrap().unwrap());
642 assert_eq!(
643 entries.next().unwrap(),
644 SysusersEntry::Group {
645 name: "root".into(),
646 id: Some(0.into()),
647 }
648 );
649 assert_eq!(
650 entries.next().unwrap(),
651 SysusersEntry::Group {
652 name: "bin".into(),
653 id: Some(1.into()),
654 }
655 );
656 assert_eq!(entries.count(), 28);
657 Ok(())
658 }
659
660 fn newroot() -> Result<cap_std_ext::cap_tempfile::TempDir> {
661 let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
662 root.create_dir("etc")?;
663 root.write("etc/passwd", b"")?;
664 root.write("etc/group", b"")?;
665 root.create_dir_all(SYSUSERSD)?;
666 root.atomic_replace_with(
667 Utf8Path::new(SYSUSERSD).join("setup.conf"),
668 |w| -> std::io::Result<()> {
669 w.write_all(SYSUSERS_REF.as_bytes())?;
670 w.write_all(SYSGROUPS_REF.as_bytes())?;
671 Ok(())
672 },
673 )?;
674 Ok(root)
675 }
676
677 #[test]
678 fn test_missing() -> Result<()> {
679 let root = &newroot()?;
680
681 let a = analyze(&root).unwrap();
682 assert!(a.is_empty());
683
684 root.write(
685 "etc/passwd",
686 indoc! { r#"
687 root:x:0:0:Super User:/root:/bin/bash
688 passim:x:982:982:Local Caching Server:/usr/share/empty:/usr/bin/nologin
689 avahi:x:70:70:Avahi mDNS/DNS-SD Stack:/var/run/avahi-daemon:/sbin/nologin
690 "#},
691 )?;
692 root.write(
693 "etc/group",
694 indoc! { r#"
695 root:x:0:
696 adm:x:4:
697 wheel:x:10:
698 sudo:x:16:
699 systemd-journal:x:190:
700 printadmin:x:983:
701 rpc:x:32:
702 passim:x:982:
703 avahi:x:70:
704 sshd:x:981:
705 "#},
706 )?;
707
708 let a = analyze(&root).unwrap();
709 assert!(!a.is_empty());
710 let missing = a.missing_users.iter().map(|s| s.as_str());
711 assert!(missing.eq(["avahi", "passim"]));
712 let missing = a.missing_groups.iter().map(|s| s.as_str());
713 assert!(missing.eq([
714 "avahi",
715 "passim",
716 "printadmin",
717 "rpc",
718 "sshd",
719 "sudo",
720 "systemd-journal"
721 ]));
722
723 Ok(())
724 }
725}