bootc_sysusers/
lib.rs

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