Skip to main content

bootc_sysusers/
lib.rs

1//! Parse and generate systemd sysusers.d entries.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4pub 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/// An error when processing sysusers
20#[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
37/// The type of Result.
38pub type Result<T> = std::result::Result<T, Error>;
39
40/// In sysusers, a user can refer to a group via name or number
41#[derive(Debug, PartialEq, Eq)]
42pub enum GroupReference {
43    /// A numeric reference
44    Numeric(u32),
45    /// A named reference
46    Name(String),
47    /// A file path
48    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/// In sysusers a uid can be defined statically or via a file path
73#[derive(Debug, PartialEq, Eq)]
74pub enum IdSource {
75    /// A numeric uid
76    Numeric(u32),
77    /// The uid is defined by the owner of this path
78    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/// A parsed sysusers.d entry
101#[derive(Debug, PartialEq, Eq)]
102#[allow(missing_docs)]
103pub enum SysusersEntry {
104    /// Defines a user
105    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    /// Defines a group
114    Group { name: String, id: Option<IdSource> },
115    /// Defines a range of uids
116    Range { start: u32, end: u32 },
117}
118
119impl SysusersEntry {
120    /// Given an input string, finds the next "token" which is normally delimited by
121    /// whitespace, but "quoted strings" are also supported. Returns that token
122    /// and the remainder. If there are no more tokens, this returns None.
123    ///
124    /// Yes this is a lot of manual parsing and there's a ton of crates we could use,
125    /// like winnow, but this problem domain is *just* simple enough that I decided
126    /// not to learn that yet.
127    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            // In the case of a sysusers entry that is of unknown type, we skip it out of conservatism
209            _ => return Ok(None),
210        };
211        Ok(Some(r))
212    }
213}
214
215/// Read all tmpfiles.d entries in the target directory, and return a mapping
216/// from (file path) => (single tmpfiles.d entry line)
217pub 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                    // Users implicitly create a group with the same name
249                    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                    // Ignore others.
266                }
267            }
268        }
269    }
270    Ok(result)
271}
272
273/// The result of analyzing /etc/{passwd,group} in a root vs systemd-sysusers.
274#[derive(Debug, Default)]
275pub struct SysusersAnalysis {
276    /// Entries which are found in /etc/passwd but not present in systemd-sysusers.
277    pub missing_users: BTreeSet<String>,
278    /// Entries which are found in /etc/group but not present in systemd-sysusers.
279    pub missing_groups: BTreeSet<String>,
280}
281
282impl SysusersAnalysis {
283    /// Returns true if this analysis finds no missing entries.
284    pub fn is_empty(&self) -> bool {
285        self.missing_users.is_empty() && self.missing_groups.is_empty()
286    }
287}
288
289/// Analyze the state of /etc/passwd vs systemd-sysusers.
290pub 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        // If there's no /etc/passwd then we're done
307        return Ok(SysusersAnalysis::default());
308    };
309
310    let mut passwd = passwd
311        .into_iter()
312        .map(|mut e| {
313            // Make the name be the map key, leaving the old value a stub
314            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            // Make the name be the map key, leaving the old value a stub
324            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                    // Nothing to do here
345                }
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    /// Non-default sysusers found in the wild
427    const OTHER_SYSUSERS_REF: &str = indoc! { r#"
428        u qemu 107:qemu "qemu user" - -
429        u vboxadd -:1 - /var/run/vboxadd -
430    "#};
431
432    /// Taken from man sysusers.d
433    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}