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