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