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        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            // In the case of a sysusers entry that is of unknown type, we skip it out of conservatism
206            _ => return Ok(None),
207        };
208        Ok(Some(r))
209    }
210}
211
212/// Read all tmpfiles.d entries in the target directory, and return a mapping
213/// from (file path) => (single tmpfiles.d entry line)
214pub 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                    // Users implicitly create a group with the same name
246                    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                    // Ignore others.
263                }
264            }
265        }
266    }
267    Ok(result)
268}
269
270/// The result of analyzing /etc/{passwd,group} in a root vs systemd-sysusers.
271#[derive(Debug, Default)]
272pub struct SysusersAnalysis {
273    /// Entries which are found in /etc/passwd but not present in systemd-sysusers.
274    pub missing_users: BTreeSet<String>,
275    /// Entries which are found in /etc/group but not present in systemd-sysusers.
276    pub missing_groups: BTreeSet<String>,
277}
278
279impl SysusersAnalysis {
280    /// Returns true if this analysis finds no missing entries.
281    pub fn is_empty(&self) -> bool {
282        self.missing_users.is_empty() && self.missing_groups.is_empty()
283    }
284}
285
286/// Analyze the state of /etc/passwd vs systemd-sysusers.
287pub 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        // If there's no /etc/passwd then we're done
304        return Ok(SysusersAnalysis::default());
305    };
306
307    let mut passwd = passwd
308        .into_iter()
309        .map(|mut e| {
310            // Make the name be the map key, leaving the old value a stub
311            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            // Make the name be the map key, leaving the old value a stub
321            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                    // Nothing to do here
342                }
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    /// Non-default sysusers found in the wild
424    const OTHER_SYSUSERS_REF: &str = indoc! { r#"
425        u qemu 107:qemu "qemu user" - -
426        u vboxadd -:1 - /var/run/vboxadd -
427    "#};
428
429    /// Taken from man sysusers.d
430    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}