Skip to main content

bootc_lib/
generator.rs

1use std::io::BufRead;
2
3use anyhow::{Context, Result};
4use camino::{Utf8Path, Utf8PathBuf};
5use cap_std::fs::Dir;
6use cap_std_ext::{cap_std, dirext::CapStdExtDirExt};
7use fn_error_context::context;
8use ostree_ext::container_utils::{OSTREE_BOOTED, is_ostree_booted_in};
9use ostree_ext::{gio, ostree};
10use rustix::{fd::AsFd, fs::StatVfsMountFlags};
11
12use crate::bootc_composefs::{boot::BootType, state::read_boot_type, status::ComposefsCmdline};
13use crate::composefs_consts::ABOOT_RECONCILE_SERVICE;
14use crate::install::DESTRUCTIVE_CLEANUP;
15use linux_kernel_cmdline::utf8::Cmdline;
16
17const STATUS_ONBOOT_UNIT: &str = "bootc-status-updated-onboot.target";
18const STATUS_PATH_UNIT: &str = "bootc-status-updated.path";
19const CLEANUP_UNIT: &str = "bootc-destructive-cleanup.service";
20const MULTI_USER_TARGET: &str = "multi-user.target";
21const EDIT_UNIT: &str = "bootc-fstab-edit.service";
22const FSTAB_ANACONDA_STAMP: &str = "Created by anaconda";
23pub(crate) const BOOTC_EDITED_STAMP: &str = "Updated by bootc-fstab-edit.service";
24const TRANSIENT_RELABEL_UNIT: &str = "bootc-early-overlay-relabel.service";
25const SYSINIT_TARGET: &str = "sysinit.target";
26const SHADOW_SYNC_UNIT: &str = "bootc-sysusers-shadow-sync.service";
27const EARLY_TMPFILES_UNIT: &str = "bootc-early-tmpfiles.service";
28
29/// Called when the root is read-only composefs to reconcile /etc/fstab
30#[context("bootc generator")]
31pub(crate) fn fstab_generator_impl(root: &Dir, unit_dir: &Dir) -> Result<bool> {
32    // Do nothing if not ostree-booted
33    if !is_ostree_booted_in(root)? {
34        return Ok(false);
35    }
36
37    if let Some(fd) = root
38        .open_optional("etc/fstab")
39        .context("Opening /etc/fstab")?
40        .map(std::io::BufReader::new)
41    {
42        let mut from_anaconda = false;
43        let mut root_is_readonly = false;
44        for line in fd.lines() {
45            let line = line.context("Reading /etc/fstab")?;
46            if line.contains(BOOTC_EDITED_STAMP) {
47                // We're done
48                return Ok(false);
49            }
50            if line.contains(FSTAB_ANACONDA_STAMP) {
51                from_anaconda = true;
52            }
53            let fields = line.split_ascii_whitespace().collect::<Vec<_>>();
54            if !line.trim_start().starts_with('#')
55                && fields.get(1) == Some(&"/")
56                && fields
57                    .get(3)
58                    .is_some_and(|options| options.split(',').any(|option| option == "ro"))
59            {
60                root_is_readonly = true;
61            }
62        }
63        if !from_anaconda || root_is_readonly {
64            return Ok(false);
65        }
66        tracing::debug!("/etc/fstab from anaconda: {from_anaconda}");
67        if from_anaconda {
68            generate_fstab_editor(unit_dir)?;
69            return Ok(true);
70        }
71    }
72    Ok(false)
73}
74
75pub(crate) fn enable_unit(unitdir: &Dir, name: &str, target: &str) -> Result<()> {
76    let wants = Utf8PathBuf::from(format!("{target}.wants"));
77    unitdir
78        .create_dir_all(&wants)
79        .with_context(|| format!("Creating {wants}"))?;
80    let source = format!("/usr/lib/systemd/system/{name}");
81    let target = wants.join(name);
82    unitdir.remove_file_optional(&target)?;
83    unitdir
84        .symlink_contents(&source, &target)
85        .with_context(|| format!("Writing {name}"))?;
86    Ok(())
87}
88
89/// Enable our units
90pub(crate) fn unit_enablement_impl(sysroot: &Dir, unit_dir: &Dir) -> Result<()> {
91    for unit in [STATUS_ONBOOT_UNIT, STATUS_PATH_UNIT] {
92        enable_unit(unit_dir, unit, MULTI_USER_TARGET)?;
93    }
94
95    if sysroot.try_exists(DESTRUCTIVE_CLEANUP)? {
96        tracing::debug!("Found {DESTRUCTIVE_CLEANUP}");
97        enable_unit(unit_dir, CLEANUP_UNIT, MULTI_USER_TARGET)?;
98    } else {
99        tracing::debug!("Didn't find {DESTRUCTIVE_CLEANUP}");
100    }
101
102    Ok(())
103}
104
105/// Main entrypoint for the generator
106pub(crate) fn generator(root: &Dir, unit_dir: &Dir) -> Result<()> {
107    // Detect composefs and ostree once, reuse across all blocks below.
108    //
109    // composefs always uses overlayfs, so check the filesystem magic first as
110    // a cheap gate.  Then inspect the mount source to distinguish regular
111    // composefs ("composefs:<digest>") from transient ("transient:composefs=<digest>").
112    let is_overlayfs = rustix::fs::fstatfs(root.as_fd())?.f_type == libc::OVERLAYFS_SUPER_MAGIC;
113    let root_source = if is_overlayfs {
114        match bootc_mount::inspect_filesystem(camino::Utf8Path::new("/")) {
115            Ok(fs) => Some(fs.source),
116            Err(e) => {
117                tracing::debug!("Could not inspect root filesystem: {e:#}");
118                None
119            }
120        }
121    } else {
122        None
123    };
124    let root_is_transient = root_source
125        .as_deref()
126        .is_some_and(|s| s.starts_with("transient:composefs="));
127    let is_composefs = root_is_transient
128        || root_source
129            .as_deref()
130            .is_some_and(|s| s.starts_with("composefs:"));
131    let is_ostree = root.try_exists(OSTREE_BOOTED)?;
132
133    // === Relabel unit: runs for transient composefs boots (native or ostree) ===
134    // Must be before the ostree-booted guard because native composefs boots do
135    // not write /run/ostree-booted, but still need the relabel unit when any
136    // transient overlay is active.
137    //
138    // Two triggering conditions, detected independently:
139    //
140    // 1. Transient root: the initramfs sets the overlay source to
141    //    "transient:composefs=<digest>" in /proc/self/mountinfo.  Detected
142    //    via root_is_transient above.
143    //
144    // 2. Transient /etc: this is mounted by bootc-root-setup.service
145    //    which runs *after* the generator, so fstatvfs would see the read-only
146    //    composefs at generator time.  Read setup-root-conf.toml directly from
147    //    the booted image instead.
148    if is_composefs {
149        let submounts_are_transient = bootc_initramfs_setup::config_has_transient_submounts(
150            std::path::Path::new(bootc_initramfs_setup::SETUP_ROOT_CONF_PATH),
151        );
152        if root_is_transient || submounts_are_transient {
153            tracing::debug!(
154                root_is_transient,
155                submounts_are_transient,
156                "Transient overlay detected; generating relabel unit"
157            );
158            generate_transient_overlay_relabel(unit_dir)?;
159        }
160    }
161
162    // === Shadow sync unit: runs for ALL bootc systems (native composefs or ostree) ===
163    // Must be before the ostree-booted guard because native composefs boots do
164    // not write /run/ostree-booted, but still need the shadow sync to clean up
165    // stale shadow/gshadow entries (the rechunk scenario).  Gate on either a
166    // composefs mount source (native composefs boot) or the ostree-booted marker.
167    if is_composefs || is_ostree {
168        let updated = shadow_sync_generator_impl(root, unit_dir)?;
169        tracing::trace!("Enabled shadow sync: {updated}");
170    }
171
172    // === Early /var/lib/systemd: native composefs boots only ===
173    // On ostree boots, ostree-remount.service provides
174    //   Before=systemd-tmpfiles-setup.service
175    //   Before=systemd-random-seed.service
176    // which (together with the generated var.mount unit) ensures tmpfiles
177    // creates /var/lib/systemd before any service tries to use it.
178    //
179    // On native composefs boots neither ostree-remount nor a var.mount unit
180    // exist, so systemd-tmpfiles-setup and systemd-random-seed race.  If
181    // the container image ships a sparse /var (common after the var-tmpfiles
182    // lint), /var/lib/systemd may not exist yet when random-seed starts,
183    // causing an SELinux denial or ENOENT.
184    //
185    // Fix: create /var/lib/systemd from a unit with random-seed's own early
186    // ordering.  Ordering all of systemd-tmpfiles-setup (which runs after
187    // local-fs.target) before random-seed instead creates an ordering cycle
188    // whenever local-fs.target needs random-seed, as with a random-key
189    // encrypted swap and tmp.mount (After=swap.target).
190    if is_composefs && !is_ostree {
191        generate_early_tmpfiles(unit_dir)?;
192    }
193
194    aboot_generator_impl(root, unit_dir)?;
195
196    // === Ostree-specific generator logic ===
197    // Only run on ostree systems (native composefs boots skip below).
198    if !root.try_exists(OSTREE_BOOTED)? {
199        return Ok(());
200    }
201
202    let Some(ref sysroot) = root.open_dir_optional("sysroot")? else {
203        return Ok(());
204    };
205
206    unit_enablement_impl(sysroot, unit_dir)?;
207
208    // Only run for overlayfs roots (composefs mounts an overlay, regular or transient).
209    let st = rustix::fs::fstatfs(root.as_fd())?;
210    if st.f_type != libc::OVERLAYFS_SUPER_MAGIC {
211        tracing::trace!("Root is not overlayfs");
212        return Ok(());
213    }
214
215    // The fstab editor only applies to read-only composefs roots (not transient).
216    let st = rustix::fs::fstatvfs(root.as_fd())?;
217    if !st.f_flag.contains(StatVfsMountFlags::RDONLY) {
218        tracing::trace!("Root is writable, skipping fstab generator");
219        return Ok(());
220    }
221
222    let updated = fstab_generator_impl(root, unit_dir)?;
223    tracing::trace!("Generated fstab: {updated}");
224
225    Ok(())
226}
227
228/// Enable the statically-installed shadow sync unit by symlinking it into
229/// `sysinit.target.wants/` in the generator output directory.
230///
231/// The unit file itself lives at `/usr/lib/systemd/system/bootc-sysusers-shadow-sync.service`
232/// and is shipped with bootc; the generator only performs conditional enablement.
233///
234/// We check existence of `/etc/shadow` rather than writability because systemd
235/// sandboxes generators in a private read-only mount namespace (see
236/// `systemd.generator(7)`), so any writability check would always fail even
237/// though `/etc` will be on its own writable mount by the time the service
238/// actually runs.  The caller already gates on the ostree-booted marker,
239/// which guarantees we are on a bootc system where `/etc` is writable at
240/// service-run time.
241#[context("shadow sync generator")]
242pub(crate) fn shadow_sync_generator_impl(root: &Dir, unit_dir: &Dir) -> Result<bool> {
243    if !root.try_exists("etc/shadow")? {
244        tracing::trace!("/etc/shadow not found, skipping shadow sync");
245        return Ok(false);
246    }
247
248    tracing::debug!("/etc/shadow found, enabling {SHADOW_SYNC_UNIT}");
249    enable_unit(unit_dir, SHADOW_SYNC_UNIT, "sysinit.target")?;
250    Ok(true)
251}
252
253fn aboot_generator_impl(root: &Dir, unit_dir: &Dir) -> Result<bool> {
254    let Some(cmdline) = root.read_to_string_optional("proc/cmdline")? else {
255        return Ok(false);
256    };
257    let Some(cmdline) = ComposefsCmdline::find_in_cmdline(&Cmdline::from(cmdline.as_str()))? else {
258        return Ok(false);
259    };
260    let Some(sysroot) = root.open_dir_optional("sysroot")? else {
261        return Ok(false);
262    };
263    if read_boot_type(&sysroot, &cmdline.digest)? != Some(BootType::Aboot) {
264        return Ok(false);
265    }
266    enable_unit(unit_dir, ABOOT_RECONCILE_SERVICE, MULTI_USER_TARGET)?;
267    Ok(true)
268}
269
270/// Parse /etc/fstab and check if the root mount is out of sync with the composefs
271/// state, and if so, fix it.
272fn generate_fstab_editor(unit_dir: &Dir) -> Result<()> {
273    unit_dir.atomic_write(
274        EDIT_UNIT,
275        "[Unit]\n\
276DefaultDependencies=no\n\
277After=systemd-fsck-root.service\n\
278Before=local-fs-pre.target local-fs.target shutdown.target systemd-remount-fs.service\n\
279\n\
280[Service]\n\
281Type=oneshot\n\
282RemainAfterExit=yes\n\
283ExecStart=bootc internals fixup-etc-fstab\n\
284",
285    )?;
286    let target = "local-fs-pre.target.wants";
287    unit_dir.create_dir_all(target)?;
288    unit_dir.symlink(&format!("../{EDIT_UNIT}"), &format!("{target}/{EDIT_UNIT}"))?;
289    Ok(())
290}
291
292/// On native composefs boots (no ostree-remount), ensure /var/lib/systemd
293/// exists before services that need it.
294///
295/// Without this, systemd-tmpfiles-setup and systemd-random-seed race: both
296/// are wanted by sysinit.target with no ordering between them.  If the
297/// container image has a sparse /var (e.g. after `rm -rf /var/lib` for the
298/// var-tmpfiles lint), /var/lib/systemd won't exist when random-seed starts.
299/// A .mount unit (e.g. var-lib-nfs-rpc_pipefs.mount) may even create
300/// /var/lib with an unlabeled_t SELinux context before policy loads, causing
301/// an AVC denial when random-seed tries to mkdir /var/lib/systemd.
302///
303/// The generated unit runs `systemd-tmpfiles` for just /var/lib/systemd,
304/// ordered only after what random-seed itself is ordered after, so it can't
305/// add an ordering cycle; see bootc-dev/bootc#2624.
306///
307/// On ostree boots, ostree-remount.service already provides this ordering.
308fn generate_early_tmpfiles(unit_dir: &Dir) -> Result<()> {
309    unit_dir.atomic_write(
310        EARLY_TMPFILES_UNIT,
311        include_str!("units/bootc-early-tmpfiles.service"),
312    )?;
313    let wants = format!("{SYSINIT_TARGET}.wants");
314    unit_dir.create_dir_all(&wants)?;
315    let link = format!("{wants}/{EARLY_TMPFILES_UNIT}");
316    unit_dir.remove_file_optional(&link)?;
317    unit_dir.symlink(&format!("../{EARLY_TMPFILES_UNIT}"), &link)?;
318    tracing::debug!("Generated {EARLY_TMPFILES_UNIT} for composefs boot");
319    Ok(())
320}
321
322/// Generate a oneshot service that relabels the transient overlay inode
323/// after SELinux policy loads, fixing the tmpfs_t label SELinux assigns to
324/// overlay upper-dir inodes at policy-load time.
325fn generate_transient_overlay_relabel(unit_dir: &Dir) -> Result<()> {
326    unit_dir.atomic_write(
327        TRANSIENT_RELABEL_UNIT,
328        include_str!("units/bootc-early-overlay-relabel.service"),
329    )?;
330    let wants = format!("{SYSINIT_TARGET}.wants");
331    unit_dir.create_dir_all(&wants)?;
332    unit_dir.symlink(
333        &format!("../{TRANSIENT_RELABEL_UNIT}"),
334        &format!("{wants}/{TRANSIENT_RELABEL_UNIT}"),
335    )?;
336    Ok(())
337}
338
339/// Relabel transient overlay mount point inodes using the running SELinux policy.
340/// Called by the generated bootc-early-overlay-relabel.service oneshot to fix
341/// the tmpfs_t label that fs_use_trans assigns to overlay upper-dir inodes at
342/// policy-load time.  Each of /, /etc, /var is relabelled iff it is a writable
343/// overlayfs (i.e. a transient overlay, not the read-only composefs).
344pub(crate) fn relabel_overlay_mountpoints() -> Result<()> {
345    let policy = ostree::SePolicy::new(&gio::File::for_path("/"), gio::Cancellable::NONE)
346        .context("Loading SELinux policy")?;
347    for path in ["/", "/etc", "/var"] {
348        let dir = Dir::open_ambient_dir(path, cap_std::ambient_authority())
349            .with_context(|| format!("Opening {path}"))?;
350        let st = rustix::fs::fstatfs(dir.as_fd())?;
351        if st.f_type != libc::OVERLAYFS_SUPER_MAGIC {
352            tracing::trace!("{path} is not an overlayfs mount, skipping relabel");
353            continue;
354        }
355        let stv = rustix::fs::fstatvfs(dir.as_fd())?;
356        if stv.f_flag.contains(StatVfsMountFlags::RDONLY) {
357            tracing::trace!("{path} is a read-only overlayfs (composefs), skipping relabel");
358            continue;
359        }
360        let metadata = dir.metadata(".").with_context(|| format!("stat {path}"))?;
361        crate::lsm::relabel(
362            &dir,
363            &metadata,
364            Utf8Path::new("."),
365            Some(Utf8Path::new(path)),
366            &policy,
367        )
368        .with_context(|| format!("Relabelling {path}"))?;
369        tracing::debug!("Relabelled {path}");
370    }
371    Ok(())
372}
373
374#[cfg(test)]
375mod tests {
376    use camino::Utf8Path;
377
378    use super::*;
379
380    fn fixture() -> Result<cap_std_ext::cap_tempfile::TempDir> {
381        let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
382        tempdir.create_dir("etc")?;
383        tempdir.create_dir("run")?;
384        tempdir.create_dir("sysroot")?;
385        tempdir.create_dir_all("run/systemd/system")?;
386        Ok(tempdir)
387    }
388
389    #[test]
390    fn aboot_reconcile_enablement() -> Result<()> {
391        let digest = "ab".repeat(64);
392        for boot_type in [None, Some("bls"), Some("uki"), Some("aboot")] {
393            let root = fixture()?;
394            let units = root.open_dir("run/systemd/system")?;
395            assert!(!aboot_generator_impl(&root, &units)?);
396            root.create_dir("proc")?;
397            root.atomic_write(
398                "proc/cmdline",
399                format!("composefs=?{digest} androidboot.slot_suffix=_a"),
400            )?;
401            if let Some(boot_type) = boot_type {
402                let path = format!(
403                    "sysroot/{}/{digest}",
404                    crate::composefs_consts::STATE_DIR_RELATIVE
405                );
406                root.create_dir_all(&path)?;
407                root.atomic_write(
408                    format!("{path}/{digest}.origin"),
409                    format!("[boot]\nboot_type={boot_type}\n"),
410                )?;
411            }
412            let expected = boot_type == Some("aboot");
413            for _ in 0..2 {
414                assert_eq!(aboot_generator_impl(&root, &units)?, expected);
415            }
416            let link = format!("{MULTI_USER_TARGET}.wants/{ABOOT_RECONCILE_SERVICE}");
417            if expected {
418                assert_eq!(
419                    units.read_link_contents(link)?,
420                    std::path::Path::new("/usr/lib/systemd/system").join(ABOOT_RECONCILE_SERVICE)
421                );
422            } else {
423                assert_eq!(units.entries()?.count(), 0);
424            }
425        }
426        Ok(())
427    }
428
429    #[test]
430    fn aboot_reconcile_with_dual_format_cmdline() -> Result<()> {
431        use composefs_ctl::composefs::erofs::format::FormatVersion;
432        use composefs_ctl::composefs::fsverity::{FsVerityHashValue, Sha512HashValue};
433
434        let root = fixture()?;
435        let units = root.open_dir("run/systemd/system")?;
436        root.create_dir("proc")?;
437        let v1 = Sha512HashValue::from_hex("ab".repeat(64))?;
438        let v2 = Sha512HashValue::from_hex("cd".repeat(64))?;
439        root.atomic_write(
440            "proc/cmdline",
441            format!(
442                "{} {} androidboot.slot_suffix=_a",
443                crate::bootc_composefs::status::build_composefs_karg(
444                    v1.clone(),
445                    FormatVersion::V1,
446                    false,
447                ),
448                crate::bootc_composefs::status::build_composefs_karg(v2, FormatVersion::V2, false,),
449            ),
450        )?;
451        let digest = v1.to_hex();
452        let path = format!(
453            "sysroot/{}/{digest}",
454            crate::composefs_consts::STATE_DIR_RELATIVE
455        );
456        root.create_dir_all(&path)?;
457        root.atomic_write(
458            format!("{path}/{digest}.origin"),
459            "[boot]\nboot_type=aboot\n",
460        )?;
461
462        assert!(aboot_generator_impl(&root, &units)?);
463        Ok(())
464    }
465
466    #[test]
467    fn test_generator_no_fstab() -> Result<()> {
468        let tempdir = fixture()?;
469        let unit_dir = &tempdir.open_dir("run/systemd/system")?;
470        fstab_generator_impl(&tempdir, &unit_dir).unwrap();
471
472        assert_eq!(unit_dir.entries()?.count(), 0);
473        Ok(())
474    }
475
476    #[test]
477    fn test_units() -> Result<()> {
478        let tempdir = &fixture()?;
479        let sysroot = &tempdir.open_dir("sysroot").unwrap();
480        let unit_dir = &tempdir.open_dir("run/systemd/system")?;
481
482        let verify = |wantsdir: &Dir, n: u32| -> Result<()> {
483            assert_eq!(unit_dir.entries()?.count(), 1);
484            let r = wantsdir.read_link_contents(STATUS_ONBOOT_UNIT)?;
485            let r: Utf8PathBuf = r.try_into().unwrap();
486            assert_eq!(r, format!("/usr/lib/systemd/system/{STATUS_ONBOOT_UNIT}"));
487            assert_eq!(wantsdir.entries()?.count(), n as usize);
488            anyhow::Ok(())
489        };
490
491        // Explicitly run this twice to test idempotency
492
493        unit_enablement_impl(sysroot, &unit_dir).unwrap();
494        unit_enablement_impl(sysroot, &unit_dir).unwrap();
495        let wantsdir = &unit_dir.open_dir("multi-user.target.wants")?;
496        verify(wantsdir, 2)?;
497        assert!(
498            wantsdir
499                .symlink_metadata_optional(CLEANUP_UNIT)
500                .unwrap()
501                .is_none()
502        );
503
504        // Now create sysroot and rerun the generator
505        unit_enablement_impl(sysroot, &unit_dir).unwrap();
506        verify(wantsdir, 2)?;
507
508        // Create the destructive stamp
509        sysroot
510            .create_dir_all(Utf8Path::new(DESTRUCTIVE_CLEANUP).parent().unwrap())
511            .unwrap();
512        sysroot.atomic_write(DESTRUCTIVE_CLEANUP, b"").unwrap();
513        unit_enablement_impl(sysroot, unit_dir).unwrap();
514        verify(wantsdir, 3)?;
515
516        // And now the unit should be enabled
517        assert!(
518            wantsdir
519                .symlink_metadata(CLEANUP_UNIT)
520                .unwrap()
521                .is_symlink()
522        );
523
524        Ok(())
525    }
526
527    #[cfg(test)]
528    mod test {
529        use super::*;
530
531        use ostree_ext::container_utils::OSTREE_BOOTED;
532
533        #[test]
534        fn test_generator_fstab() -> Result<()> {
535            let tempdir = fixture()?;
536            let unit_dir = &tempdir.open_dir("run/systemd/system")?;
537            // Should still be a no-op
538            tempdir.atomic_write("etc/fstab", "# Some dummy fstab")?;
539            fstab_generator_impl(&tempdir, &unit_dir).unwrap();
540            assert_eq!(unit_dir.entries()?.count(), 0);
541
542            // Also a no-op, not booted via ostree
543            tempdir.atomic_write("etc/fstab", &format!("# {FSTAB_ANACONDA_STAMP}"))?;
544            fstab_generator_impl(&tempdir, &unit_dir).unwrap();
545            assert_eq!(unit_dir.entries()?.count(), 0);
546
547            // Now it should generate
548            tempdir.atomic_write(OSTREE_BOOTED, "ostree booted")?;
549            fstab_generator_impl(&tempdir, &unit_dir).unwrap();
550            assert_eq!(unit_dir.entries()?.count(), 2);
551
552            Ok(())
553        }
554
555        #[test]
556        fn test_transient_overlay_relabel_generated() -> Result<()> {
557            let tempdir = fixture()?;
558            let unit_dir = &tempdir.open_dir("run/systemd/system")?;
559
560            // We can't fake fstatfs or findmnt, so call generate_transient_overlay_relabel directly.
561            generate_transient_overlay_relabel(unit_dir)?;
562
563            // The unit file must exist
564            assert!(unit_dir.try_exists(TRANSIENT_RELABEL_UNIT)?);
565            // The symlink in sysinit.target.wants must point at the generated unit
566            let wants = format!("{SYSINIT_TARGET}.wants");
567            let link = unit_dir.read_link_contents(format!("{wants}/{TRANSIENT_RELABEL_UNIT}"))?;
568            let link: camino::Utf8PathBuf = link.try_into().unwrap();
569            assert_eq!(link, format!("../{TRANSIENT_RELABEL_UNIT}"));
570            // The unit must invoke bootc internals relabel-overlay-mountpoints
571            let content = unit_dir.read_to_string(TRANSIENT_RELABEL_UNIT)?;
572            assert!(
573                content.contains("ExecStart=bootc internals relabel-overlay-mountpoints"),
574                "unexpected unit content: {content}"
575            );
576
577            Ok(())
578        }
579
580        #[test]
581        fn test_transient_overlay_relabel_idempotent() -> Result<()> {
582            let tempdir = fixture()?;
583            let unit_dir = &tempdir.open_dir("run/systemd/system")?;
584
585            // Calling generate_transient_overlay_relabel twice must succeed
586            generate_transient_overlay_relabel(unit_dir)?;
587            // Second call: atomic_write overwrites the unit file; symlink already exists
588            // (symlink won't be re-created because the dir already contains it).
589            // The test just checks the call doesn't error.
590            // We need to remove the old symlink first (same as how enable_unit does it).
591            let wants = format!("{SYSINIT_TARGET}.wants");
592            unit_dir.remove_file_optional(format!("{wants}/{TRANSIENT_RELABEL_UNIT}"))?;
593            generate_transient_overlay_relabel(unit_dir)?;
594
595            assert!(unit_dir.try_exists(TRANSIENT_RELABEL_UNIT)?);
596
597            Ok(())
598        }
599
600        #[test]
601        fn test_generator_fstab_idempotent() -> Result<()> {
602            let anaconda_fstab = indoc::indoc! { "
603#
604# /etc/fstab
605# Created by anaconda on Tue Mar 19 12:24:29 2024
606#
607# Accessible filesystems, by reference, are maintained under '/dev/disk/'.
608# See man pages fstab(5), findfs(8), mount(8) and/or blkid(8) for more info.
609#
610# After editing this file, run 'systemctl daemon-reload' to update systemd
611# units generated from this file.
612#
613# Updated by bootc-fstab-edit.service
614UUID=715be2b7-c458-49f2-acec-b2fdb53d9089 /                       xfs     ro              0 0
615UUID=341c4712-54e8-4839-8020-d94073b1dc8b /boot                   xfs     defaults        0 0
616" };
617            let tempdir = fixture()?;
618            let unit_dir = &tempdir.open_dir("run/systemd/system")?;
619
620            tempdir.atomic_write("etc/fstab", anaconda_fstab)?;
621            tempdir.atomic_write(OSTREE_BOOTED, "ostree booted")?;
622            let updated = fstab_generator_impl(&tempdir, &unit_dir).unwrap();
623            assert!(!updated);
624            assert_eq!(unit_dir.entries()?.count(), 0);
625
626            Ok(())
627        }
628
629        #[test]
630        fn test_generator_fstab_readonly_root() -> Result<()> {
631            let anaconda_fstab = indoc::indoc! { "
632# Created by anaconda
633UUID=715be2b7-c458-49f2-acec-b2fdb53d9089 / xfs defaults,ro 0 0
634" };
635            let tempdir = fixture()?;
636            let unit_dir = &tempdir.open_dir("run/systemd/system")?;
637
638            tempdir.atomic_write("etc/fstab", anaconda_fstab)?;
639            tempdir.atomic_write(OSTREE_BOOTED, "ostree booted")?;
640            let updated = fstab_generator_impl(&tempdir, unit_dir)?;
641            assert!(!updated);
642            assert_eq!(unit_dir.entries()?.count(), 0);
643
644            Ok(())
645        }
646
647        /// Values of every `key=` line in the unit, split on whitespace.
648        fn unit_values<'a>(unit: &'a str, key: &str) -> Vec<&'a str> {
649            unit.lines()
650                .filter_map(|l| l.strip_prefix(key)?.strip_prefix('='))
651                .flat_map(str::split_ascii_whitespace)
652                .collect()
653        }
654
655        #[test]
656        fn test_early_tmpfiles_unit() -> Result<()> {
657            let tempdir = fixture()?;
658            let unit_dir = &tempdir.open_dir("run/systemd/system")?;
659
660            // Twice, to check it is idempotent
661            for _ in 0..2 {
662                generate_early_tmpfiles(unit_dir)?;
663            }
664
665            let unit = unit_dir.read_to_string(EARLY_TMPFILES_UNIT)?;
666            assert_eq!(
667                unit_values(&unit, "ExecStart"),
668                ["systemd-tmpfiles", "--create", "--prefix=/var/lib/systemd"]
669            );
670            assert_eq!(
671                unit_values(&unit, "Before"),
672                [
673                    "systemd-random-seed.service",
674                    "systemd-tpm2-setup.service",
675                    "sysinit.target",
676                    "shutdown.target"
677                ]
678            );
679            // Nothing beyond what systemd-random-seed.service is ordered
680            // after itself: in particular not local-fs.target, which
681            // random-seed may have to precede (bootc-dev/bootc#2624).
682            assert_eq!(unit_values(&unit, "DefaultDependencies"), ["no"]);
683            assert_eq!(unit_values(&unit, "After"), ["systemd-remount-fs.service"]);
684            assert_eq!(
685                unit_values(&unit, "RequiresMountsFor"),
686                ["/var/lib/systemd"]
687            );
688
689            let link = unit_dir
690                .read_link_contents(format!("{SYSINIT_TARGET}.wants/{EARLY_TMPFILES_UNIT}"))?;
691            assert_eq!(link, std::path::Path::new("..").join(EARLY_TMPFILES_UNIT));
692
693            Ok(())
694        }
695
696        #[test]
697        fn test_shadow_sync_no_shadow() -> Result<()> {
698            // No /etc/shadow => should not enable (boot detection is caller's job)
699            let tempdir = fixture()?;
700            let unit_dir = &tempdir.open_dir("run/systemd/system")?;
701            let generated = shadow_sync_generator_impl(&tempdir, unit_dir)?;
702            assert!(!generated);
703            assert_eq!(unit_dir.entries()?.count(), 0);
704            Ok(())
705        }
706
707        #[test]
708        fn test_shadow_sync_enables_when_shadow_present() -> Result<()> {
709            // /etc/shadow present => enables the static unit
710            let tempdir = fixture()?;
711            tempdir.atomic_write("etc/shadow", "root:*:18912:0:99999:7:::\n")?;
712            let unit_dir = &tempdir.open_dir("run/systemd/system")?;
713            let generated = shadow_sync_generator_impl(&tempdir, unit_dir)?;
714            assert!(generated);
715            // The generator creates a symlink in sysinit.target.wants/; check the
716            // directory entry exists (symlink_contents creates an absolute-path symlink
717            // that cap-std won't follow in a tempdir, so we check metadata directly).
718            let wants = unit_dir.open_dir("sysinit.target.wants")?;
719            let meta = wants.symlink_metadata(SHADOW_SYNC_UNIT)?;
720            assert!(meta.is_symlink(), "expected symlink for {SHADOW_SYNC_UNIT}");
721            Ok(())
722        }
723
724        /// Verify that generator() enables the shadow sync unit on traditional
725        /// ostree boots (tmpfs root, OSTREE_BOOTED marker present).
726        #[test]
727        fn test_generator_shadow_sync_on_non_composefs() -> Result<()> {
728            let tempdir = fixture()?;
729            tempdir.atomic_write(OSTREE_BOOTED, "")?;
730            tempdir.atomic_write("etc/shadow", "root:*:18912:0:99999:7:::\n")?;
731            let unit_dir = &tempdir.open_dir("run/systemd/system")?;
732            generator(&tempdir, unit_dir)?;
733            let wants = unit_dir.open_dir("sysinit.target.wants")?;
734            let meta = wants.symlink_metadata(SHADOW_SYNC_UNIT)?;
735            assert!(
736                meta.is_symlink(),
737                "shadow sync unit must be enabled on non-composefs ostree systems"
738            );
739            Ok(())
740        }
741    }
742}