Skip to main content

bootc_lib/
sysusers_cleanup.rs

1//! Remove orphaned and duplicate entries from `/etc/shadow` and `/etc/gshadow`
2//! before `systemd-sysusers` runs, preventing fatal "already exists" errors.
3//!
4//! The canonical trigger for this problem is the ublue/rechunk tooling, which
5//! resets `/etc/group` (and optionally `/etc/passwd`) but leaves the shadow
6//! files untouched, producing stale entries.  When `systemd-sysusers` then
7//! tries to create those users/groups it finds them already in the shadow files
8//! and fatally errors, causing subsequent entries to be skipped.
9//!
10//! This module is invoked as `bootc internals sysusers-sync` by the static
11//! `bootc-sysusers-shadow-sync.service` unit, which the generator symlinks into
12//! `sysinit.target.wants/` and which runs `Before=systemd-sysusers.service`.
13
14// SPDX-License-Identifier: Apache-2.0 OR MIT
15
16use std::collections::HashSet;
17use std::io::Write;
18
19use anyhow::{Context, Result};
20use cap_std_ext::cap_std;
21use cap_std_ext::cap_std::fs::Dir;
22use cap_std_ext::dirext::CapStdExtDirExt;
23use fn_error_context::context;
24use ostree_ext::ostree;
25
26// ── helpers ──────────────────────────────────────────────────────────────────
27
28/// Load usernames from a passwd-format file at `path` within `root`, returning
29/// an empty set if the file doesn't exist.
30fn load_usernames(root: &Dir, path: &str) -> Result<HashSet<String>> {
31    use bootc_sysusers::nameservice::passwd::parse_passwd_content;
32    let mut names = HashSet::new();
33    if let Some(f) = root
34        .open_optional(path)
35        .with_context(|| format!("Opening {path}"))?
36    {
37        let entries = parse_passwd_content(std::io::BufReader::new(f))
38            .with_context(|| format!("Parsing {path}"))?;
39        names.extend(entries.into_iter().map(|e| e.name));
40    }
41    Ok(names)
42}
43
44/// Load group names from a group-format file at `path` within `root`, returning
45/// an empty set if the file doesn't exist.
46fn load_groupnames(root: &Dir, path: &str) -> Result<HashSet<String>> {
47    use bootc_sysusers::nameservice::group::parse_group_content;
48    let mut names = HashSet::new();
49    if let Some(f) = root
50        .open_optional(path)
51        .with_context(|| format!("Opening {path}"))?
52    {
53        let entries = parse_group_content(std::io::BufReader::new(f))
54            .with_context(|| format!("Parsing {path}"))?;
55        names.extend(entries.into_iter().map(|e| e.name));
56    }
57    Ok(names)
58}
59
60// ── RemovedEntries ────────────────────────────────────────────────────────────
61
62/// Entries removed from a shadow-style file, split by reason.
63#[derive(Debug, Default)]
64struct RemovedEntries {
65    orphaned: Vec<String>,
66    duplicates: Vec<String>,
67}
68
69impl RemovedEntries {
70    fn is_empty(&self) -> bool {
71        self.orphaned.is_empty() && self.duplicates.is_empty()
72    }
73}
74
75// ── filter_shadow_file ────────────────────────────────────────────────────────
76
77/// Remove entries from a shadow-style file whose name is not in `valid_names`
78/// or which are duplicates (keeping first occurrence). Returns the sets of
79/// removed entry names, or `None` if the file does not exist. Logging is left
80/// to the caller.
81///
82/// When `sepolicy` is provided the rewritten file is labeled according to the
83/// policy (using the file's canonical absolute path, e.g. `/etc/shadow`). This
84/// preserves the correct SELinux label (`shadow_t` / `gshadow_t`) on the
85/// atomically replaced tempfile, which would otherwise inherit `etc_t` from the
86/// directory's default transition rules.
87fn filter_shadow_file<T>(
88    root: &Dir,
89    path: &str,
90    valid_names: &HashSet<String>,
91    name_fn: impl Fn(&T) -> &str,
92    serialize_fn: impl Fn(&T, &mut Vec<u8>) -> Result<()>,
93    parse_fn: impl Fn(std::io::BufReader<cap_std::fs::File>) -> Result<Vec<T>>,
94    sepolicy: Option<&ostree::SePolicy>,
95) -> Result<Option<RemovedEntries>> {
96    let Some(f) = root
97        .open_optional(path)
98        .with_context(|| format!("Opening {path}"))?
99    else {
100        return Ok(None);
101    };
102    let meta = f.metadata().with_context(|| format!("Stat {path}"))?;
103    use cap_std::fs::MetadataExt as _;
104    let mode = rustix::fs::Mode::from_raw_mode(meta.mode());
105    let entries = parse_fn(std::io::BufReader::new(f))?;
106
107    let mut seen = HashSet::new();
108    let mut removed = RemovedEntries::default();
109
110    let filtered: Vec<T> = entries
111        .into_iter()
112        .filter(|e| {
113            let name = name_fn(e);
114            if !valid_names.contains(name) {
115                removed.orphaned.push(name.to_string());
116                return false;
117            }
118            if !seen.insert(name.to_string()) {
119                removed.duplicates.push(name.to_string());
120                return false;
121            }
122            true
123        })
124        .collect();
125
126    if removed.is_empty() {
127        return Ok(Some(removed));
128    }
129
130    let mut buf = Vec::new();
131    for entry in &filtered {
132        serialize_fn(entry, &mut buf)?;
133    }
134    crate::lsm::atomic_replace_labeled(root, path, mode, sepolicy, |w| {
135        w.write_all(&buf).map_err(Into::into)
136    })
137    .with_context(|| format!("Rewriting {path}"))?;
138
139    Ok(Some(removed))
140}
141
142// ── PwdLock ───────────────────────────────────────────────────────────────────
143
144/// RAII guard that holds the shadow-utils password-file lock (`/etc/.pwd.lock`)
145/// for the duration of its lifetime, matching the locking convention used by
146/// `shadow-utils` (`lckpwdf(3)`) and `systemd-sysusers`.
147///
148/// `locked` tracks whether `lckpwdf` was actually called so that `Drop` only
149/// calls `ulckpwdf` when the lock is genuinely held.
150struct PwdLock {
151    locked: bool,
152}
153
154impl PwdLock {
155    /// Acquire the lock only when `root` is the real root filesystem.
156    /// When operating on a tempdir (unit tests, image builds) lckpwdf would
157    /// try to lock the *host* `/etc/.pwd.lock`, which is wrong, so we skip it.
158    fn acquire_for_root(root: &Dir) -> Result<Self> {
159        #[allow(unsafe_code)]
160        unsafe extern "C" {
161            fn lckpwdf() -> libc::c_int;
162        }
163        // Check if this Dir refers to the real root by comparing its device/inode
164        // to that of "/". If it doesn't, skip locking.
165        let root_meta = root.dir_metadata()?;
166        let real_root_meta = std::fs::metadata("/")?;
167        use cap_std_ext::cap_primitives::fs::MetadataExt as CapMetadataExt;
168        use std::os::unix::fs::MetadataExt;
169        if root_meta.dev() != real_root_meta.dev() || root_meta.ino() != real_root_meta.ino() {
170            tracing::trace!("skipping lckpwdf: not operating on real root");
171            return Ok(PwdLock { locked: false });
172        }
173        // lckpwdf() blocks up to 15 seconds then returns -1 on timeout.
174        #[allow(unsafe_code)]
175        let r = unsafe { lckpwdf() };
176        if r != 0 {
177            anyhow::bail!("lckpwdf() failed: could not acquire /etc/.pwd.lock");
178        }
179        Ok(PwdLock { locked: true })
180    }
181}
182
183impl Drop for PwdLock {
184    fn drop(&mut self) {
185        if !self.locked {
186            return;
187        }
188        #[allow(unsafe_code)]
189        unsafe extern "C" {
190            fn ulckpwdf() -> libc::c_int;
191        }
192        #[allow(unsafe_code)]
193        let _ = unsafe { ulckpwdf() };
194    }
195}
196
197// ── public entry point ────────────────────────────────────────────────────────
198
199/// Remove orphaned and duplicate entries from `/etc/shadow` and `/etc/gshadow`.
200///
201/// For `/etc/shadow`: an entry is orphaned if the username does not appear in
202/// `/etc/passwd` OR `/usr/lib/passwd`. Both are checked because nss-altfiles
203/// places real system users in `/usr/lib/passwd` and those users legitimately
204/// have shadow entries for local PAM authentication.
205///
206/// For `/etc/gshadow`: an entry is orphaned if the group name does not appear
207/// in `/etc/group` OR `/usr/lib/group`. The symmetry with shadow/passwd is
208/// intentional: nss-altfiles places groups in `/usr/lib/group` and those groups
209/// legitimately have gshadow entries. A gshadow entry is only stale when the
210/// group has dropped from *both* locations (the rechunk scenario).
211///
212/// This runs as `bootc-sysusers-shadow-sync.service` before
213/// `systemd-sysusers.service` to prevent fatal "already exists" errors when
214/// sysusers tries to create users/groups whose shadow entries are stale.
215#[context("Fixing orphaned/duplicate entries in /etc/shadow and /etc/gshadow")]
216pub(crate) fn run(root: &Dir) -> Result<()> {
217    use bootc_sysusers::nameservice::gshadow::{GshadowEntry, parse_gshadow_content};
218    use bootc_sysusers::nameservice::shadow::{ShadowEntry, parse_shadow_content};
219
220    // Acquire the shadow-utils/systemd-sysusers lock (/etc/.pwd.lock) for the
221    // duration of this function so our read-modify-write is atomic with respect
222    // to any other process that honours the same locking convention.
223    let _lock = PwdLock::acquire_for_root(root)?;
224
225    // Load the SELinux policy for this root so that rewritten shadow files get
226    // the correct label (e.g. `shadow_t` / `gshadow_t`) rather than inheriting
227    // the directory default (`etc_t`) from the atomically created tempfile.
228    // Returns None on non-SELinux systems or when no policy csum is found.
229    let sepolicy = crate::lsm::new_sepolicy_at(root)?;
230    let sepolicy = sepolicy.as_ref();
231
232    // Build valid user set from both /etc/passwd and /usr/lib/passwd.
233    // nss-altfiles users in /usr/lib/passwd legitimately have shadow entries.
234    let mut valid_users = load_usernames(root, "etc/passwd")?;
235    valid_users.extend(load_usernames(root, "usr/lib/passwd")?);
236
237    // Build valid group set from both /etc/group and /usr/lib/group.
238    // nss-altfiles groups in /usr/lib/group legitimately have gshadow entries.
239    // A gshadow entry is only orphaned when the group is absent from both.
240    let mut valid_groups = load_groupnames(root, "etc/group")?;
241    valid_groups.extend(load_groupnames(root, "usr/lib/group")?);
242
243    // If we couldn't find any valid users at all, skip to avoid
244    // incorrectly wiping shadow on a minimal/unusual system.
245    if valid_users.is_empty() {
246        tracing::debug!("No /etc/passwd or /usr/lib/passwd found, skipping shadow fixup");
247        return Ok(());
248    }
249
250    if let Some(removed) = filter_shadow_file(
251        root,
252        "etc/shadow",
253        &valid_users,
254        |e: &ShadowEntry| e.namp.as_str(),
255        |e, buf| e.to_writer(buf),
256        parse_shadow_content,
257        sepolicy,
258    )? {
259        if !removed.is_empty() {
260            tracing::info!(
261                "etc/shadow: removed {} orphaned ({}), {} duplicate ({}) entries",
262                removed.orphaned.len(),
263                removed.orphaned.join(", "),
264                removed.duplicates.len(),
265                removed.duplicates.join(", "),
266            );
267        }
268    }
269
270    // Guard: if we found no groups at all from either file, skip to avoid
271    // wiping gshadow on an unusual system where group files are absent.
272    if !valid_groups.is_empty() {
273        if let Some(removed) = filter_shadow_file(
274            root,
275            "etc/gshadow",
276            &valid_groups,
277            |e: &GshadowEntry| e.name.as_str(),
278            |e, buf| e.to_writer(buf),
279            parse_gshadow_content,
280            sepolicy,
281        )? {
282            if !removed.is_empty() {
283                tracing::info!(
284                    "etc/gshadow: removed {} orphaned ({}), {} duplicate ({}) entries",
285                    removed.orphaned.len(),
286                    removed.orphaned.join(", "),
287                    removed.duplicates.len(),
288                    removed.duplicates.join(", "),
289                );
290            }
291        }
292    }
293
294    Ok(())
295}
296
297// ── tests ─────────────────────────────────────────────────────────────────────
298
299#[cfg(test)]
300mod tests {
301    use anyhow::Result;
302    use cap_std_ext::cap_std;
303    use cap_std_ext::dirext::CapStdExtDirExt;
304
305    use super::*;
306
307    fn setup_etc(td: &cap_std::fs::Dir) -> Result<()> {
308        td.create_dir_all("etc")?;
309        Ok(())
310    }
311
312    #[test]
313    fn test_fixup_shadow_no_orphans() -> Result<()> {
314        let td = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
315        setup_etc(&td)?;
316        td.atomic_write(
317            "etc/passwd",
318            "root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1::/usr/sbin:/usr/sbin/nologin\n",
319        )?;
320        td.atomic_write(
321            "etc/shadow",
322            "root:*:18912:0:99999:7:::\ndaemon:*:18474:0:99999:7:::\n",
323        )?;
324        td.atomic_write("etc/group", "root:x:0:\ndaemon:x:1:\n")?;
325        td.atomic_write("etc/gshadow", "root:*::\ndaemon:*::\n")?;
326
327        run(&td)?;
328
329        assert_eq!(
330            td.read_to_string("etc/shadow")?,
331            "root:*:18912:0:99999:7:::\ndaemon:*:18474:0:99999:7:::\n"
332        );
333        assert_eq!(td.read_to_string("etc/gshadow")?, "root:*::\ndaemon:*::\n");
334        Ok(())
335    }
336
337    #[test]
338    fn test_fixup_shadow_orphaned_entry() -> Result<()> {
339        let td = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
340        setup_etc(&td)?;
341        td.atomic_write("etc/passwd", "root:x:0:0:root:/root:/bin/bash\n")?;
342        // plocate is in shadow but NOT in passwd or usr/lib/passwd
343        td.atomic_write(
344            "etc/shadow",
345            "root:*:18912:0:99999:7:::\nplocate:!!:::::::\n",
346        )?;
347        td.atomic_write("etc/group", "root:x:0:\n")?;
348        td.atomic_write("etc/gshadow", "root:*::\nplocate:!::\n")?;
349
350        run(&td)?;
351
352        // plocate removed from both
353        assert_eq!(
354            td.read_to_string("etc/shadow")?,
355            "root:*:18912:0:99999:7:::\n"
356        );
357        assert_eq!(td.read_to_string("etc/gshadow")?, "root:*::\n");
358        Ok(())
359    }
360
361    #[test]
362    fn test_fixup_shadow_nss_altfiles_group_gshadow_kept() -> Result<()> {
363        // plocate is in /usr/lib/group (nss-altfiles) but NOT in /etc/group.
364        // Its /etc/gshadow entry must be KEPT — the group is legitimately present
365        // in the system via nss-altfiles, so the gshadow entry is valid.
366        // Only when the group drops from BOTH /etc/group and /usr/lib/group is
367        // the gshadow entry considered orphaned.
368        let td = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
369        td.create_dir_all("etc")?;
370        td.create_dir_all("usr/lib")?;
371        td.atomic_write("etc/passwd", "root:x:0:0:root:/root:/bin/bash\n")?;
372        td.atomic_write("etc/shadow", "root:*:18912:0:99999:7:::\n")?;
373        td.atomic_write("etc/group", "root:x:0:\n")?;
374        // plocate in usr/lib/group (nss-altfiles) but not in etc/group
375        td.atomic_write("usr/lib/group", "plocate:x:999:\n")?;
376        td.atomic_write("etc/gshadow", "root:*::\nplocate:!::\n")?;
377
378        run(&td)?;
379
380        // plocate gshadow entry must be KEPT — group is valid via /usr/lib/group
381        assert_eq!(td.read_to_string("etc/gshadow")?, "root:*::\nplocate:!::\n");
382        Ok(())
383    }
384
385    #[test]
386    fn test_fixup_shadow_nss_altfiles_group_gshadow_removed() -> Result<()> {
387        // The core rechunk/ublue scenario: plocate dropped from BOTH /etc/group
388        // and /usr/lib/group, but the stale gshadow entry remains.
389        // It must be removed so systemd-sysusers can re-create the group cleanly.
390        let td = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
391        td.create_dir_all("etc")?;
392        td.create_dir_all("usr/lib")?;
393        td.atomic_write("etc/passwd", "root:x:0:0:root:/root:/bin/bash\n")?;
394        td.atomic_write("etc/shadow", "root:*:18912:0:99999:7:::\n")?;
395        td.atomic_write("etc/group", "root:x:0:\n")?;
396        // plocate absent from both /etc/group and /usr/lib/group
397        td.atomic_write("etc/gshadow", "root:*::\nplocate:!::\n")?;
398
399        run(&td)?;
400
401        // plocate gshadow entry must be REMOVED — absent from both group files
402        assert_eq!(td.read_to_string("etc/gshadow")?, "root:*::\n");
403        Ok(())
404    }
405
406    #[test]
407    fn test_fixup_shadow_nss_altfiles_passwd_user_kept() -> Result<()> {
408        // Users in /usr/lib/passwd (nss-altfiles) legitimately have shadow entries
409        // because /etc/shadow is always local. Their shadow entries must be preserved.
410        let td = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
411        td.create_dir_all("etc")?;
412        td.create_dir_all("usr/lib")?;
413        // bin is in /usr/lib/passwd (nss-altfiles), not in /etc/passwd
414        td.atomic_write("etc/passwd", "root:x:0:0:root:/root:/bin/bash\n")?;
415        td.atomic_write("usr/lib/passwd", "bin:x:1:1:bin:/bin:/sbin/nologin\n")?;
416        // bin has a shadow entry — this is valid and must be preserved
417        td.atomic_write("etc/shadow", "root:*:18912:0:99999:7:::\nbin:!!:::::::\n")?;
418        td.atomic_write("etc/group", "root:x:0:\nbin:x:1:\n")?;
419        td.atomic_write("etc/gshadow", "root:*::\nbin:*::\n")?;
420
421        run(&td)?;
422
423        // bin shadow entry preserved (bin is in /usr/lib/passwd)
424        assert_eq!(
425            td.read_to_string("etc/shadow")?,
426            "root:*:18912:0:99999:7:::\nbin:!!:::::::\n"
427        );
428        Ok(())
429    }
430
431    #[test]
432    fn test_fixup_shadow_duplicate_removed() -> Result<()> {
433        let td = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
434        setup_etc(&td)?;
435        td.atomic_write("etc/passwd", "root:x:0:0:root:/root:/bin/bash\n")?;
436        // root appears twice in shadow
437        td.atomic_write(
438            "etc/shadow",
439            "root:*:18912:0:99999:7:::\nroot:*:18912:0:99999:7:::\n",
440        )?;
441        td.atomic_write("etc/group", "root:x:0:\n")?;
442        td.atomic_write("etc/gshadow", "root:*::\n")?;
443
444        run(&td)?;
445
446        // duplicate removed, first kept
447        assert_eq!(
448            td.read_to_string("etc/shadow")?,
449            "root:*:18912:0:99999:7:::\n"
450        );
451        Ok(())
452    }
453
454    #[test]
455    fn test_fixup_shadow_no_passwd_skips() -> Result<()> {
456        // No /etc/passwd at all => skip shadow fixup entirely (safety guard)
457        let td = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
458        setup_etc(&td)?;
459        td.atomic_write(
460            "etc/shadow",
461            "root:*:18912:0:99999:7:::\nplocate:!!:::::::\n",
462        )?;
463        td.atomic_write("etc/gshadow", "root:*::\nplocate:!::\n")?;
464
465        run(&td)?;
466
467        // Files unchanged — we didn't know what's valid
468        assert_eq!(
469            td.read_to_string("etc/shadow")?,
470            "root:*:18912:0:99999:7:::\nplocate:!!:::::::\n"
471        );
472        Ok(())
473    }
474}